packages feed

moonlight-planar-1.1.0.0: src-public/Moonlight/Hex/Planar.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}

-- | Exact planar interpretation of native hexagonal cells and regions.
module Moonlight.Hex.Planar
  ( HexPlanarObstruction (..)
  , hexCellExactLoop
  , hexRegionPlanarRegion
  , hexRegionByCenterInPlanarRegion
  , hexRegionFullyCoveredByPlanarRegion
  , hexRegionIntersectingPlanarRegion
  ) where

import Control.DeepSeq (NFData)
import Control.Monad ((<=<))
import Data.Bifunctor (first)
import Data.Either (isRight)
import Data.Foldable (foldlM)
import Data.List qualified as List
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Ratio ((%))
import Data.Set (Set)
import Data.Set qualified as Set
import GHC.Generics (Generic)
import Moonlight.Hex.Coordinate
  ( HexCoord (..)
  , HexDirection
  , allHexDirections
  , hexStepCoord
  )
import Moonlight.Hex.Element
  ( HexVertex
  , hexBoundaryFrom
  , hexBoundaryTo
  , hexCellBoundarySide
  , hexCellVertices
  , hexVertexCoordinates
  )
import Moonlight.Hex.Region
  ( HexLayout
  , HexRegion
  , HexRowSpan
  , foldHexRegionCoords
  , hexLayoutCellCount
  , hexLayoutOrigin
  , hexLayoutWidth
  , hexRegionGenerate
  , hexRegionGenerateRowSpans
  , hexRegionMember
  , hexRowSpan
  )
import Moonlight.Planar.Exact
  ( ExactPoint
  , ExactRational
  , SegmentRelation (SegmentsDisjoint)
  , exactOrient2d
  , exactPoint
  , exactPointCoordinates
  , exactRationalDenominator
  , exactRationalNumerator
  , exactSegmentRelation
  )
import Moonlight.Planar.Region
  ( ExactLoop
  , PlanarRegion
  , RegionPointLocation (RegionExterior)
  , RegionValidationError
  , emptyPlanarRegion
  , exactLoop
  , exactLoopPoints
  , planarRegion
  , planarRegionComponents
  , polygonComponent
  , polygonHoleLoops
  , polygonOuterLoop
  , regionPointLocation
  )

-- | Every typed obstruction in boundary descent and exact publication.
data HexPlanarObstruction
  = HexPlanarRegionInvalid !RegionValidationError
  | HexPlanarBoundaryOpen !HexVertex
  | HexPlanarBoundaryVectorUnexpected !HexVertex !HexVertex
  | HexPlanarBoundaryDegenerate !(NonEmpty HexVertex)
  | HexPlanarHoleUnowned !ExactLoop
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

hexCellExactLoop :: HexCoord -> Either HexPlanarObstruction ExactLoop
hexCellExactLoop =
  first HexPlanarRegionInvalid
    . exactLoop
    . fmap exactVertexPoint
    . hexCellVertices

-- | Trace the exterior sides of the selected native section, classify the
-- resulting exact loops by winding, and publish one canonical planar region.
-- Interior cell sides never enter the boundary graph.
hexRegionPlanarRegion :: HexRegion -> Either HexPlanarObstruction PlanarRegion
hexRegionPlanarRegion region
  | Set.null boundary = Right emptyPlanarRegion
  | otherwise = do
      vertexLoops <- traceBoundaryLoops boundary
      exactLoops <- traverse admitBoundaryLoop vertexLoops
      publishClassifiedLoops exactLoops
 where
  boundary = regionBoundaryEdges region

  admitBoundaryLoop
    :: NonEmpty HexVertex
    -> Either HexPlanarObstruction (Integer, ExactLoop)
  admitBoundaryLoop vertices = do
    let area = signedDoubleArea vertices
    if area == 0
      then Left (HexPlanarBoundaryDegenerate vertices)
      else
        fmap
          (\loop -> (area, loop))
          (first HexPlanarRegionInvalid (exactLoop (fmap exactVertexPoint vertices)))

-- | Select cells whose exact centre belongs to the closed planar region.
hexRegionByCenterInPlanarRegion :: HexLayout -> PlanarRegion -> HexRegion
hexRegionByCenterInPlanarRegion layout region
  | hexLayoutCellCount layout == 1 =
      hexRegionGenerate layout ((/= RegionExterior) . regionPointLocation region . exactCellCentre)
  | otherwise =
      let segments = axialBoundarySegments (planarBoundary region)
       in hexRegionGenerateRowSpans layout
            (mapMaybe (uncurry hexRowSpan) . centreRowRanges segments)

-- | Select cells whose entire closed hexagon is covered by the closed planar
-- region. A closed convex cell lies in the region exactly when its centre
-- does and no boundary segment of the region meets the cell's interior: the
-- interior is connected, so once the boundary is known to miss it, it lies
-- wholly on the centre's side. Boundary-only contact is therefore coverage,
-- and no overlay or gap publication is involved.
hexRegionFullyCoveredByPlanarRegion :: HexLayout -> PlanarRegion -> HexRegion
hexRegionFullyCoveredByPlanarRegion layout region
  | hexLayoutCellCount layout == 1 =
      let boundary = planarBoundary region
       in hexRegionGenerate layout (cellCoveredByRegion region (boundarySegments boundary))
  | otherwise = restrictPlanarBoundary layout region (cellCoveredByRegion region . boundarySegments)

-- | Select cells whose closed hexagon has any point in common with the closed
-- planar region. The region boundary is prepared once as an ephemeral view;
-- the admitted 'PlanarRegion' remains the sole geometry authority.
hexRegionIntersectingPlanarRegion :: HexLayout -> PlanarRegion -> HexRegion
hexRegionIntersectingPlanarRegion layout region =
  restrictPlanarBoundary layout region (cellIntersectsRegion region)

-- | Descent from whole centre runs away from the boundary, with the original
-- detailed classifier retained on a conservative exact boundary supercover.
-- A singleton context has no shared run work: it uses the original direct
-- observation rather than constructing an event plan around one predicate.
restrictPlanarBoundary
  :: HexLayout
  -> PlanarRegion
  -> (PlanarBoundary -> HexCoord -> Bool)
  -> HexRegion
restrictPlanarBoundary layout region classify
  | hexLayoutCellCount layout == 1 = hexRegionGenerate layout (classify boundary)
  | otherwise = hexRegionGenerateRowSpans layout selectedSpans
 where
  boundary = planarBoundary region
  segments = axialBoundarySegments boundary
  firstQ = toInteger (hexQ (hexLayoutOrigin layout))
  finalQ = firstQ + toInteger (hexLayoutWidth layout) - 1

  selectedSpans :: Int -> [HexRowSpan]
  selectedSpans row =
    let boundaryRanges = boundaryRowRanges layout segments row
        interiorRanges =
          if boundaryRanges == [(firstQ, finalQ)]
            then []
            else subtractRowRanges (centreRowRanges segments row) boundaryRanges
        selectedBoundary =
          [ (q, q)
          | (firstBoundaryQ, finalBoundaryQ) <- boundaryRanges
          , q <- [firstBoundaryQ .. finalBoundaryQ]
          , classify boundary (HexCoord (fromInteger q) row)
          ]
     in mapMaybe (uncurry hexRowSpan) (mergeRowRanges (interiorRanges <> selectedBoundary))
{-# INLINE restrictPlanarBoundary #-}

-- Query-local scalar observations of the existing exact boundary. The
-- positive-determinant affine coordinates are (u,v)=(x,3*y-x), placing axial
-- centres at (3*q,6*r). They author no second polygon or geometry inventory.
type AxialBoundarySegment = ((Rational, Rational), (Rational, Rational))
type RowRange = (Integer, Integer)

axialBoundarySegments :: PlanarBoundary -> [AxialBoundarySegment]
axialBoundarySegments = fmap axialSegment . boundarySegments
 where
  axialSegment :: (Extent, ExactPoint, ExactPoint) -> AxialBoundarySegment
  axialSegment (_, from, to) = (axialPoint from, axialPoint to)
  axialPoint :: ExactPoint -> (Rational, Rational)
  axialPoint point =
    let (x, y) = exactPointCoordinates point
        rational :: ExactRational -> Rational
        rational value = exactRationalNumerator value % exactRationalDenominator value
        u = rational x
     in (u, 3 * rational y - u)

-- | Pair half-open crossings of admitted closed loops, then separately add
-- closed boundary intersections and collinear spans. The former has even
-- parity; the latter preserves tangencies and vertices on integer centres.
centreRowRanges :: [AxialBoundarySegment] -> Int -> [RowRange]
centreRowRanges segments row =
  mergeRowRanges (mapMaybe quantizeRowRange (interiorSpans <> closedBoundarySpans))
 where
  rowV = fromInteger (6 * toInteger row)
  crossings = List.sort
    [ crossing
    | segment@((_, fromV), (_, toV)) <- segments
    , min fromV toV <= rowV && rowV < max fromV toV
    , Just crossing <- [crossingAt rowV segment]
    ]
  interiorSpans =
    [ pair
    | (index, pair) <- zip [0 :: Int ..] (zip crossings (drop 1 crossings))
    , even index
    ]
  closedBoundarySpans =
    [ boundarySpan
    | segment@((fromU, fromV), (toU, toV)) <- segments
    , min fromV toV <= rowV && rowV <= max fromV toV
    , boundarySpan <-
        if fromV == toV
          then [(min fromU toU, max fromU toU)]
          else maybe [] (\crossing -> [(crossing, crossing)]) (crossingAt rowV segment)
    ]

-- A horizontal support has no transverse crossing. All arithmetic remains
-- exact; neither row events nor their order are projected through binary64.
crossingAt :: Rational -> AxialBoundarySegment -> Maybe Rational
crossingAt rowV ((fromU, fromV), (toU, toV))
  | fromV == toV = Nothing
  | otherwise = Just (fromU + (rowV - fromV) * (toU - fromU) / (toV - fromV))

quantizeRowRange :: (Rational, Rational) -> Maybe RowRange
quantizeRowRange (firstU, finalU) =
  let firstQ = ceiling (firstU / 3)
      finalQ = floor (finalU / 3)
   in if firstQ <= finalQ then Just (firstQ, finalQ) else Nothing

-- | Every point of a hexagon lies in u +/- 2 and v +/- 4 about its centre.
-- Clip a segment to the CLOSED v strip before expanding its u interval; this
-- avoids the rectangular-area over-enumeration of an untrimmed diagonal.
-- Endpoints and loops contained inside a cell are retained by construction.
boundaryRowRanges :: HexLayout -> [AxialBoundarySegment] -> Int -> [RowRange]
boundaryRowRanges layout segments row =
  mergeRowRanges (mapMaybe (clipToLayout <=< quantizeRowRange <=< stripSpan) segments)
 where
  rowV = fromInteger (6 * toInteger row)
  lowerV = rowV - 4
  upperV = rowV + 4
  minimumQ = toInteger (hexQ (hexLayoutOrigin layout))
  maximumQ = minimumQ + toInteger (hexLayoutWidth layout) - 1

  stripSpan :: AxialBoundarySegment -> Maybe (Rational, Rational)
  stripSpan ((fromU, fromV), (toU, toV))
    | max fromV toV < lowerV || min fromV toV > upperV = Nothing
    | fromV == toV = Just (min fromU toU - 2, max fromU toU + 2)
    | otherwise =
        let firstParameter = (lowerV - fromV) / (toV - fromV)
            finalParameter = (upperV - fromV) / (toV - fromV)
            lowerParameter = max 0 (min firstParameter finalParameter)
            upperParameter = min 1 (max firstParameter finalParameter)
            firstU = fromU + lowerParameter * (toU - fromU)
            finalU = fromU + upperParameter * (toU - fromU)
         in Just (min firstU finalU - 2, max firstU finalU + 2)

  clipToLayout :: RowRange -> Maybe RowRange
  clipToLayout (firstQ, finalQ) =
    let clippedFirst = max minimumQ firstQ
        clippedFinal = min maximumQ finalQ
     in if clippedFirst <= clippedFinal then Just (clippedFirst, clippedFinal) else Nothing

mergeRowRanges :: [RowRange] -> [RowRange]
mergeRowRanges = reverse . foldl' merge [] . List.sortOn fst
 where
  merge :: [RowRange] -> RowRange -> [RowRange]
  merge [] range = [range]
  merge ((previousFirst, previousFinal) : remaining) range@(firstQ, finalQ)
    | firstQ <= previousFinal + 1 = (previousFirst, max previousFinal finalQ) : remaining
    | otherwise = range : (previousFirst, previousFinal) : remaining

subtractRowRanges :: [RowRange] -> [RowRange] -> [RowRange]
subtractRowRanges = foldl' (\remaining cut -> concatMap (subtractRange cut) remaining)
 where
  subtractRange :: RowRange -> RowRange -> [RowRange]
  subtractRange (cutFirst, cutFinal) range@(firstQ, finalQ)
    | cutFinal < firstQ || finalQ < cutFirst = [range]
    | otherwise =
        [(firstQ, cutFirst - 1) | firstQ < cutFirst]
          <> [(cutFinal + 1, finalQ) | cutFinal < finalQ]

exactCellCentre :: HexCoord -> ExactPoint
exactCellCentre (HexCoord q r) =
  exactPoint
    (fromInteger (3 * toInteger q))
    (fromInteger (2 * toInteger r + toInteger q))

-- | Closed axis-aligned extent, the cheap separation test in front of every
-- exact predicate: two closed sets with disjoint extents are disjoint.
data Extent = Extent !ExactRational !ExactRational !ExactRational !ExactRational

pointExtent :: ExactPoint -> Extent
pointExtent point =
  let (x, y) = exactPointCoordinates point
   in Extent x x y y

pointsExtent :: NonEmpty ExactPoint -> Extent
pointsExtent = foldr1 joinExtent . fmap pointExtent
 where
  joinExtent (Extent leftLow leftHigh leftBottom leftTop) (Extent rightLow rightHigh rightBottom rightTop) =
    Extent (min leftLow rightLow) (max leftHigh rightHigh) (min leftBottom rightBottom) (max leftTop rightTop)

extentsMeet :: Extent -> Extent -> Bool
extentsMeet (Extent leftLow leftHigh leftBottom leftTop) (Extent rightLow rightHigh rightBottom rightTop) =
  leftLow <= rightHigh && rightLow <= leftHigh && leftBottom <= rightTop && rightBottom <= leftTop

-- | The region boundary as an ephemeral view: every boundary vertex and every
-- boundary segment, each with its extent.
data PlanarBoundary = PlanarBoundary
  { boundaryPoints :: ![(Extent, ExactPoint)]
  , boundarySegments :: ![(Extent, ExactPoint, ExactPoint)]
  }

planarBoundary :: PlanarRegion -> PlanarBoundary
planarBoundary region =
  let loops = planarRegionComponents region >>= componentLoops
      points = loops >>= NonEmpty.toList . exactLoopPoints
   in PlanarBoundary
        { boundaryPoints = fmap (\point -> (pointExtent point, point)) points
        , boundarySegments =
            [ (pointsExtent (from :| [to]), from, to)
            | loop <- loops
            , (from, to) <- exactPointCycleSegments (NonEmpty.toList (exactLoopPoints loop))
            ]
        }
 where
  componentLoops component =
    polygonOuterLoop component : polygonHoleLoops component

cellCoveredByRegion :: PlanarRegion -> [(Extent, ExactPoint, ExactPoint)] -> HexCoord -> Bool
cellCoveredByRegion region segments coordinate =
  regionPointLocation region (exactCellCentre coordinate) /= RegionExterior
    && not
      ( any
          (\(extent, from, to) -> extentsMeet cellExtent extent && segmentMeetsConvexInterior cellPoints (from, to))
          segments
      )
 where
  cellVertices = fmap exactVertexPoint (hexCellVertices coordinate)
  cellPoints = NonEmpty.toList cellVertices
  cellExtent = pointsExtent cellVertices

-- | Whether a segment has a point strictly inside the convex polygon whose
-- vertices are listed counter-clockwise. A segment on a side's line never
-- enters; otherwise it enters when an endpoint is strictly inside, when it
-- properly crosses a side, or when it touches the boundary at two distinct
-- points, since the chord between them is interior to a convex polygon.
segmentMeetsConvexInterior :: [ExactPoint] -> (ExactPoint, ExactPoint) -> Bool
segmentMeetsConvexInterior vertices (from, to)
  | any onSideLine sides = False
  | strictlyInside fromSides || strictlyInside toSides = True
  | any properlyCrosses sides = True
  | otherwise = length (List.nub contacts) >= 2
 where
  edges = exactPointCycleSegments vertices
  fromSides = fmap (\(a, b) -> exactOrient2d a b from) edges
  toSides = fmap (\(a, b) -> exactOrient2d a b to) edges
  vertexSides = fmap (exactOrient2d from to) vertices
  sides = zip (zip fromSides toSides) (zip vertexSides (drop 1 (cycle vertexSides)))
  onSideLine :: ((Ordering, Ordering), (Ordering, Ordering)) -> Bool
  onSideLine ((fromSide, toSide), _) = fromSide == EQ && toSide == EQ
  strictlyInside :: [Ordering] -> Bool
  strictlyInside = all (== GT)
  onBoundary :: [Ordering] -> Bool
  onBoundary orientations = all (/= LT) orientations && elem EQ orientations
  properlyCrosses :: ((Ordering, Ordering), (Ordering, Ordering)) -> Bool
  properlyCrosses ((fromSide, toSide), (startSide, endSide)) =
    opposite fromSide toSide && opposite startSide endSide
  opposite :: Ordering -> Ordering -> Bool
  opposite left right = (left == GT && right == LT) || (left == LT && right == GT)
  contacts =
    [point | (point, orientations) <- [(from, fromSides), (to, toSides)], onBoundary orientations]
      <> [vertex | (vertex, side) <- zip vertices vertexSides, side == EQ, withinSpan vertex]
  withinSpan :: ExactPoint -> Bool
  withinSpan vertex = extentsMeet (pointExtent vertex) (pointsExtent (from :| [to]))

cellIntersectsRegion :: PlanarRegion -> PlanarBoundary -> HexCoord -> Bool
cellIntersectsRegion region boundary coordinate =
  any ((/= RegionExterior) . regionPointLocation region) cellPoints
    || any
      (\(extent, point) -> extentsMeet cellExtent extent && pointInClosedHex cellSegments point)
      (boundaryPoints boundary)
    || any
      (\(extent, regionFrom, regionTo) ->
         extentsMeet cellExtent extent
           && any
             (\(cellFrom, cellTo) ->
                exactSegmentRelation cellFrom cellTo regionFrom regionTo /= SegmentsDisjoint)
             cellSegments)
      (boundarySegments boundary)
 where
  cellVertices = fmap exactVertexPoint (hexCellVertices coordinate)
  cellPoints = NonEmpty.toList cellVertices
  cellSegments = exactPointCycleSegments cellPoints
  cellExtent = pointsExtent cellVertices

pointInClosedHex :: [(ExactPoint, ExactPoint)] -> ExactPoint -> Bool
pointInClosedHex cellSegments query =
  all (\(from, to) -> exactOrient2d from to query /= LT) cellSegments

exactPointCycleSegments :: [ExactPoint] -> [(ExactPoint, ExactPoint)]
exactPointCycleSegments points =
  case points of
    [] -> []
    firstPoint : remaining -> zip points (remaining <> [firstPoint])

-- One oriented edge with selected area on its left.
data BoundaryEdge = BoundaryEdge !HexVertex !HexVertex
  deriving stock (Eq, Ord, Show)

regionBoundaryEdges :: HexRegion -> Set BoundaryEdge
regionBoundaryEdges region =
  foldHexRegionCoords addCell Set.empty region
 where
  addCell :: Set BoundaryEdge -> HexCoord -> Set BoundaryEdge
  addCell edges coordinate =
    foldl' (addDirection coordinate) edges allHexDirections

  addDirection
    :: HexCoord
    -> Set BoundaryEdge
    -> HexDirection
    -> Set BoundaryEdge
  addDirection coordinate edges direction =
    case hexStepCoord coordinate direction of
      Just neighbour | hexRegionMember neighbour region -> edges
      _ ->
        let side = hexCellBoundarySide coordinate direction
         in Set.insert
              (BoundaryEdge (hexBoundaryFrom side) (hexBoundaryTo side))
              edges

traceBoundaryLoops :: Set BoundaryEdge -> Either HexPlanarObstruction [NonEmpty HexVertex]
traceBoundaryLoops edges = descend edges []
 where
  outgoing =
    Set.foldl'
      (\index edge@(BoundaryEdge from _) -> Map.insertWith (<>) from [edge] index)
      Map.empty
      edges

  descend
    :: Set BoundaryEdge
    -> [NonEmpty HexVertex]
    -> Either HexPlanarObstruction [NonEmpty HexVertex]
  descend remaining reversedLoops =
    case Set.lookupMin remaining of
      Nothing -> Right (reverse reversedLoops)
      Just firstEdge@(BoundaryEdge firstVertex _) -> do
        (vertices, unconsumed) <- traceCycle outgoing firstVertex firstEdge (Set.delete firstEdge remaining)
        descend unconsumed (vertices : reversedLoops)

traceCycle
  :: Map HexVertex [BoundaryEdge]
  -> HexVertex
  -> BoundaryEdge
  -> Set BoundaryEdge
  -> Either HexPlanarObstruction (NonEmpty HexVertex, Set BoundaryEdge)
traceCycle outgoing firstVertex firstEdge remaining =
  walk firstEdge (firstVertex :| []) remaining
 where
  walk
    :: BoundaryEdge
    -> NonEmpty HexVertex
    -> Set BoundaryEdge
    -> Either HexPlanarObstruction (NonEmpty HexVertex, Set BoundaryEdge)
  walk current reversedVertices unconsumed =
    let BoundaryEdge _ endpoint = current
     in if endpoint == firstVertex
          then Right (NonEmpty.reverse reversedVertices, unconsumed)
          else do
            next <- selectNextBoundaryEdge outgoing unconsumed current endpoint
            walk next (endpoint NonEmpty.<| reversedVertices) (Set.delete next unconsumed)

selectNextBoundaryEdge
  :: Map HexVertex [BoundaryEdge]
  -> Set BoundaryEdge
  -> BoundaryEdge
  -> HexVertex
  -> Either HexPlanarObstruction BoundaryEdge
selectNextBoundaryEdge outgoing remaining current endpoint =
  case NonEmpty.nonEmpty (filter (`Set.member` remaining) (Map.findWithDefault [] endpoint outgoing)) of
    Nothing -> Left (HexPlanarBoundaryOpen endpoint)
    Just candidates -> do
      currentRank <- boundaryEdgeRank current
      ranked <- traverse (rankCandidate currentRank) candidates
      pure (snd (leastRanked ranked))
 where
  rankCandidate
    :: Int
    -> BoundaryEdge
    -> Either HexPlanarObstruction (Int, BoundaryEdge)
  rankCandidate currentRank candidate = do
    candidateRank <- boundaryEdgeRank candidate
    pure ((candidateRank - currentRank) `mod` 6, candidate)

  leastRanked :: NonEmpty (Int, BoundaryEdge) -> (Int, BoundaryEdge)
  leastRanked (firstRanked :| rest) = foldl' choose firstRanked rest

  choose :: (Int, BoundaryEdge) -> (Int, BoundaryEdge) -> (Int, BoundaryEdge)
  choose left right = if fst left <= fst right then left else right

boundaryEdgeRank :: BoundaryEdge -> Either HexPlanarObstruction Int
boundaryEdgeRank (BoundaryEdge from to) =
  let (fromX, fromY) = hexVertexCoordinates from
      (toX, toY) = hexVertexCoordinates to
   in case (toX - fromX, toY - fromY) of
        (2, 0) -> Right 0
        (1, 1) -> Right 1
        (-1, 1) -> Right 2
        (-2, 0) -> Right 3
        (-1, -1) -> Right 4
        (1, -1) -> Right 5
        _ -> Left (HexPlanarBoundaryVectorUnexpected from to)

publishClassifiedLoops :: [(Integer, ExactLoop)] -> Either HexPlanarObstruction PlanarRegion
publishClassifiedLoops classified = do
  let outerLoops = [(area, loop) | (area, loop) <- classified, area > 0]
      holeLoops = [loop | (area, loop) <- classified, area < 0]
  holesByOuter <- foldlM (assignHole outerLoops) Map.empty holeLoops
  components <-
    traverse
      (\(_, outer) ->
          first HexPlanarRegionInvalid
            (polygonComponent outer (Map.findWithDefault [] outer holesByOuter))
      )
      outerLoops
  first HexPlanarRegionInvalid (planarRegion components)

assignHole
  :: [(Integer, ExactLoop)]
  -> Map ExactLoop [ExactLoop]
  -> ExactLoop
  -> Either HexPlanarObstruction (Map ExactLoop [ExactLoop])
assignHole outerLoops assignments hole =
  case filter (\(_, outer) -> isRight (polygonComponent outer [hole])) outerLoops of
    [] -> Left (HexPlanarHoleUnowned hole)
    firstOwner : remainingOwners ->
      let (_, owner) = foldl' smallerOuter firstOwner remainingOwners
       in Right (Map.insertWith (<>) owner [hole] assignments)
 where
  smallerOuter :: (Integer, ExactLoop) -> (Integer, ExactLoop) -> (Integer, ExactLoop)
  smallerOuter left right = if fst left <= fst right then left else right

signedDoubleArea :: NonEmpty HexVertex -> Integer
signedDoubleArea vertices@(firstVertex :| rest) =
  sum
    [ leftX * rightY - leftY * rightX
    | (left, right) <- zip (NonEmpty.toList vertices) (rest <> [firstVertex])
    , let (leftX, leftY) = hexVertexCoordinates left
    , let (rightX, rightY) = hexVertexCoordinates right
    ]

exactVertexPoint :: HexVertex -> ExactPoint
exactVertexPoint vertex =
  let (x, y) = hexVertexCoordinates vertex
   in exactPoint (fromInteger x) (fromInteger y)