packages feed

moonlight-triangulation-1.2.0.0: src-build/Moonlight/Triangulation/Internal/CircleSweep.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -O3 -fllvm -optlo-O3 -optlc-O3 #-}

module Moonlight.Triangulation.Internal.CircleSweep
  ( RadiallyOrderedArena
  , radiallyOrderArena
  , circleSweepInsert
  ) where

import Control.Monad (forM_, when)
import Control.Monad.ST (ST)
import Data.Bits (xor)
import qualified Data.Vector.Algorithms.Intro as Intro
import qualified Data.Vector.Unboxed.Mutable as MUV
import Data.Word (Word32)
import Moonlight.Triangulation.Handles.HandleDefs (DirectedEdgeId (..))
import Moonlight.Triangulation.Insertion (insertExistingVertex)
import Moonlight.Triangulation.Internal.DcelOperations
  ( ReservedSweepCells
  , SweepCellCursor
  , SweepInsertion (..)
  , closeOuterTurnReserved
  , commitReservedSweepConnections
  , fixHullConvexity
  , initialSweepCellCursor
  , insertOutsideHullAtEdge
  , reserveSweepCells
  )
import Moonlight.Triangulation.Internal.DcelOperations.CandidateArena
  ( seedGenericPairInArena
  )
import Moonlight.Triangulation.Internal.DcelOperations.Normalize
  ( LegalizationDrain (..)
  , drainDenseUnconstrainedGenericLegalization
  )
import Moonlight.Triangulation.Internal.Mutable
  ( DenseMutableDcel
  , MutableDcel
  , denseMutableDcel
  , denseMutableOwner
  , denseFaceEdges
  , denseReadFaceEdge
  , denseReadNext
  , denseReadOrigin
  , denseReadPointX
  , denseReadPointY
  , denseReadPrevious
  , directedEdgeCount
  , faceCount
  , halfEdgeCapacity
  , pointCapacity
  )
import Moonlight.Triangulation.Internal.OperationState
  ( Counter (..)
  , LegalizationArena
  , OperationState
  , addCounter
  , legalizationArena
  , maxCounter
  , readScratch
  , storeLegalizationArena
  , writeScratch
  )
import Moonlight.Triangulation.Internal.Probe (Probe (..))
import Moonlight.Triangulation.Scalar (orient2dCoordinates)
import Moonlight.Triangulation.Types (BuildError (..))

-- | The hull is an angular index over the DCEL outer-face cycle. The cycle
-- itself already owns hull adjacency, so left and right walking is
-- 'readPrevious'/'readNext' on the live mesh and this record only caches what
-- topology cannot state: the pseudo-angle of each outer edge's origin and the
-- bucket anchors accelerating the predecessor search. An outer edge's key is
-- its origin's @(angle, x, y)@ with the edge id as the final tie-break; the
-- angle lives in 'hullAngleByEdge' and the coordinates are re-read from the
-- immutable origin only when two cached angles compare exactly equal. Slots
-- of edges that have left the outer cycle are never read again, so the cache
-- needs no invalidation, and there is no second ring beside the authoritative
-- one.
data Hull s = Hull
  { hullCenterX :: {-# UNPACK #-} !Double
  , hullCenterY :: {-# UNPACK #-} !Double
  , hullBucketCapacity :: {-# UNPACK #-} !Int
  , hullAngleByEdge :: !(MUV.MVector s Double)
  }

-- | The strict state of the derived angular section. The bucket vector is
-- mutated only to transport the section across one local hull rewrite; its
-- extent and active cardinality are threaded as values, so the sweep does not
-- bounce through singleton mutable cells for facts already known at descent.
data HullIndex s = HullIndex
  !(MUV.MVector s Word32)
  {-# UNPACK #-} !Int

data DeferredInsertion s
  = DeferredInsertionFailure !BuildError
  | DeferredInsertionSuccess
      {-# UNPACK #-} !Int
      {-# UNPACK #-} !Int
      {-# UNPACK #-} !SweepCellCursor
      !(LegalizationArena s)
      {-# UNPACK #-} !(HullIndex s)

data ClosedHullSection s = ClosedHullSection
  {-# UNPACK #-} !Int
  {-# UNPACK #-} !Int
  {-# UNPACK #-} !Int
  !(LegalizationArena s)
  {-# UNPACK #-} !SweepCellCursor

-- | A packed sweep arena after its radial keys have descended to one total
-- order. The constructor is private: the circle sweep consumes the proof and
-- therefore never pays to establish the same ordering twice.
newtype RadiallyOrderedArena s = RadiallyOrderedArena
  (MUV.MVector s (Double, Double, Double, Word32))

radiallyOrderArena
  :: MUV.MVector s (Double, Double, Double, Word32)
  -> ST s (RadiallyOrderedArena s)
radiallyOrderArena arena = do
  Intro.sort arena
  pure (RadiallyOrderedArena arena)
{-# INLINE radiallyOrderArena #-}

noOuterEdge :: Word32
noOuterEdge = maxBound

-- | Circle sweep over one mutable DCEL, consuming one packed radial arena of
-- @(squaredDistance, x, y, vertex)@ records. The arena is sorted in place and
-- then read directly — no decorated freeze, no undecoration pass. Insertions
-- initially close only acute hull turns. One terminal Graham pass restores
-- full convexity, so construction does not repeatedly pay for global
-- convexity that no intermediate observer can see.
circleSweepInsert
  :: MutableDcel s vertex directed undirected face
  -> OperationState s
  -> RadiallyOrderedArena s
  -> ST s (Either BuildError Int)
circleSweepInsert mutable operation (RadiallyOrderedArena arena) =
  case denseMutableDcel mutable of
    Nothing -> pure (Left CircleSweepRequiresDenseStorage)
    Just dense -> circleSweepInsertDense dense operation arena
{-# INLINE circleSweepInsert #-}

circleSweepInsertDense
  :: DenseMutableDcel s vertex directed undirected face
  -> OperationState s
  -> MUV.MVector s (Double, Double, Double, Word32)
  -> ST s (Either BuildError Int)
circleSweepInsertDense dense operation arena
  | MUV.length arena == 0 = pure (Right 0)
  | otherwise = do
      let !ordered = MUV.length arena
      seed <- insertSeed 0
      case seed of
        Left failure -> pure (Left failure)
        Right seedCount -> do
          faces <- faceCount mutable
          if faces <= 1 || seedCount >= ordered
            then pure (Right seedCount)
            else do
              (centerX, centerY) <- seedCentre dense
              reservedOutcome <- reserveSweepCells dense (ordered - seedCount)
              case reservedOutcome of
                Left failure -> pure (Left failure)
                Right reserved -> do
                  builtHull <- buildHull dense operation centerX centerY
                  case builtHull of
                    Left failure -> pure (Left failure)
                    Right (!hull, !initialHullIndex) -> do
                      skipped <- MUV.new (ordered - seedCount)
                      initialLegalizationArena <- legalizationArena operation
                      let !initialCursor = initialSweepCellCursor reserved
                      inserted <- insertRemaining reserved initialCursor initialLegalizationArena hull initialHullIndex ordered seedCount skipped 0 0 0 0
                      case inserted of
                        Left failure -> pure (Left failure)
                        Right (!skippedCount, !fastCount, !flips, !maxDepth, !sweepCursor, !sweepArena) -> do
                          repaired <- fixHullConvexity reserved sweepCursor operation sweepArena
                          case repaired of
                            Left failure -> pure (Left failure)
                            Right (!_closures, !terminalFlips, !terminalMaxDepth, !finalArena, !finalCursor) -> do
                              commitReservedSweepConnections reserved finalCursor fastCount
                              storeLegalizationArena operation finalArena
                              -- The sweep counts its own hull insertions; its drains
                              -- hand their flip and depth tallies up once.
                              addCounter operation CounterHullInsertions fastCount
                          -- The angular candidate leaves a point to the fallback
                          -- whenever it lands right of, or on, the hull edge its
                          -- own angle selected — spade's
                          -- `is_on_right_side_or_on_line` branch, which spade's own
                          -- source calls "very slow". Both counts ride out in
                          -- BuildStats so the split is comparable across the two
                          -- implementations and satisfies
                          -- seed + fast + skipped = unique.
                          --
                          -- CounterSweepFastPoints is NOT CounterHullInsertions
                          -- renamed: the latter is also charged by
                          -- insertOutsideHull, so it counts hull-adjacent
                          -- insertions from either path and dominates this one
                          -- whenever a skipped point lands outside the hull. The
                          -- two coincide only while skipped is zero, and charging
                          -- them independently is what makes the identity a check
                          -- rather than a restatement.
                              addCounter operation CounterSweepFastPoints fastCount
                              addCounter operation CounterSweepSkippedPoints skippedCount
                              addCounter operation CounterEdgeFlips flips
                              maxCounter operation CounterLegalizationMaxStack maxDepth
                              addCounter operation CounterEdgeFlips terminalFlips
                              maxCounter operation CounterLegalizationMaxStack terminalMaxDepth
                              insertedSkipped <- insertSkipped skipped skippedCount 0
                              pure (seedCount <$ insertedSkipped)
 where
  !mutable = denseMutableOwner dense

  insertSeed !index
    | index >= MUV.length arena = pure (Right index)
    | otherwise = do
        (_, _, _, raw) <- MUV.unsafeRead arena index
        result <- insertExistingVertex @'ProbeOff mutable operation (fromIntegral raw)
        case result of
          Left failure -> pure (Left failure)
          Right () -> do
            faces <- faceCount mutable
            if faces > 1
              then pure (Right (index + 1))
              else insertSeed (index + 1)

  insertRemaining !reserved !cursor !candidateArena !hull !hullIndex !ordered !index !skipped !skippedCount !fastCount !flips !maxDepth
    | index >= ordered = pure (Right (skippedCount, fastCount, flips, maxDepth, cursor, candidateArena))
    | otherwise = do
        (_, queryXWide, queryYWide, raw) <- MUV.unsafeRead arena index
        let !vertex = fromIntegral raw
        let !queryAngle =
              pseudoAngle (hullCenterX hull) (hullCenterY hull) queryXWide queryYWide
        edge <- hullCandidate dense hull hullIndex queryAngle queryXWide queryYWide
        fromVertex <- denseReadOrigin dense edge
        toVertex <- denseReadOrigin dense (edge `xor` 1)
        fromX <- denseReadPointX dense fromVertex
        fromY <- denseReadPointY dense fromVertex
        toX <- denseReadPointX dense toVertex
        toY <- denseReadPointY dense toVertex
        if orient2dCoordinates fromX fromY toX toY queryXWide queryYWide == GT
          then do
            deferred <-
              insertDeferred
                reserved
                cursor
                dense
                hull
                hullIndex
                candidateArena
                edge
                fromVertex
                toVertex
                vertex
                queryXWide
                queryYWide
                queryAngle
            case deferred of
              DeferredInsertionFailure failure -> pure (Left failure)
              DeferredInsertionSuccess !newFlips !newMaxDepth !nextCursor !nextArena !nextHullIndex ->
                insertRemaining
                  reserved
                  nextCursor
                  nextArena
                  hull
                  nextHullIndex
                  ordered
                  (index + 1)
                  skipped
                  skippedCount
                  (fastCount + 1)
                  (flips + newFlips)
                  (max maxDepth newMaxDepth)
          else do
            MUV.unsafeWrite skipped skippedCount raw
            insertRemaining reserved cursor candidateArena hull hullIndex ordered (index + 1) skipped (skippedCount + 1) fastCount flips maxDepth

  insertSkipped skipped !count = go
   where
    go !index
      | index >= count = pure (Right ())
      | otherwise = do
          vertex <- fromIntegral <$> MUV.unsafeRead skipped index
          inserted <- insertExistingVertex @'ProbeOff mutable operation vertex
          case inserted of
            Left failure -> pure (Left failure)
            Right () -> go (index + 1)

-- | The hull centre: the centroid of the first inner face, in the widened
-- comparison format, computed exactly as @centroid@ states it.
seedCentre
  :: DenseMutableDcel s vertex directed undirected face
  -> ST s (Double, Double)
seedCentre dense = do
  (e0, e1, e2) <- denseFaceEdges dense 1
  o0 <- denseReadOrigin dense e0
  o1 <- denseReadOrigin dense e1
  o2 <- denseReadOrigin dense e2
  x0 <- denseReadPointX dense o0
  y0 <- denseReadPointY dense o0
  x1 <- denseReadPointX dense o1
  y1 <- denseReadPointY dense o1
  x2 <- denseReadPointX dense o2
  y2 <- denseReadPointY dense o2
  pure (x0 + (x1 - x0) / 3 + (x2 - x0) / 3, y0 + (y1 - y0) / 3 + (y2 - y0) / 3)

-- | Index the authoritative outer cycle. The seed fan is star-shaped around
-- the first face's centroid — a collinear chain closed by its apex — so the
-- cycle is already the angular order the predecessor search assumes; what
-- remains is caching each edge's angle and anchoring the buckets.
buildHull
  :: DenseMutableDcel s vertex directed undirected face
  -> OperationState s
  -> Double
  -> Double
  -> ST s (Either BuildError (Hull s, HullIndex s))
buildHull dense operation centerX centerY = do
  let !mutable = denseMutableOwner dense
  countResult <- collectOuterEdges dense operation
  case countResult of
    Left failure -> pure (Left failure)
    Right count
      | count <= 0 -> pure (Left CircleSweepHullEmpty)
      | otherwise -> do
          let !capacity = max (count + 4) (pointCapacity mutable + 8)
          hullAngleByEdge <- MUV.new (halfEdgeCapacity mutable)
          let hull =
                Hull
                  { hullCenterX = centerX
                  , hullCenterY = centerY
                  , hullBucketCapacity = capacity
                  , hullAngleByEdge
                  }
          forM_ [0 .. count - 1] $ \index -> do
            edge <- readScratch operation index
            origin <- denseReadOrigin dense edge
            x <- denseReadPointX dense origin
            y <- denseReadPointY dense origin
            MUV.unsafeWrite hullAngleByEdge edge (pseudoAngle centerX centerY x y)
          hullIndex <- rebuildBuckets dense hull count (initialBucketCount count capacity)
          pure (Right (hull, hullIndex))

collectOuterEdges
  :: DenseMutableDcel s vertex directed undirected face
  -> OperationState s
  -> ST s (Either BuildError Int)
collectOuterEdges dense operation = do
  let !mutable = denseMutableOwner dense
  start <- denseReadFaceEdge dense 0
  if start < 0
    then pure (Right 0)
    else do
      bound <- directedEdgeCount mutable
      go (bound + 1) start start False 0
 where
  go !remaining !start !edge !seen !count
    | remaining <= 0 =
        pure
          ( Left
              ( OuterCycleDidNotTerminate
                  (DirectedEdgeId (fromIntegral start))
                  (DirectedEdgeId (fromIntegral edge))
                  count
              )
          )
    | seen && edge == start = pure (Right count)
    | otherwise = do
        writeScratch operation count edge
        following <- denseReadNext dense edge
        go (remaining - 1) start following True (count + 1)

initialBucketCount :: Int -> Int -> Int
initialBucketCount active capacity =
  min capacity (nextPowerOfTwo (max 8 ((active + 1) `quot` 2)))

nextPowerOfTwo :: Int -> Int
nextPowerOfTwo requested = go 1
 where
  target = max 1 requested
  go !value
    | value >= target = value
    | value > maxBound `quot` 2 = maxBound
    | otherwise = go (value * 2)

readAngle :: Hull s -> Int -> ST s Double
readAngle hull edge = MUV.unsafeRead (hullAngleByEdge hull) edge
{-# INLINE readAngle #-}

rebuildBuckets :: DenseMutableDcel s vertex directed undirected face -> Hull s -> Int -> Int -> ST s (HullIndex s)
rebuildBuckets dense hull active requested = do
  let !count = max 1 (min (hullBucketCapacity hull) requested)
  buckets <- MUV.replicate count noOuterEdge
  start <- denseReadFaceEdge dense 0
  let go !remaining !edge
        | remaining <= 0 = pure ()
        | otherwise = do
            following <- denseReadNext dense edge
            angle <- readAngle hull edge
            followingAngle <- readAngle hull following
            writeBucketSegment buckets angle followingAngle edge
            go (remaining - 1) following
  when (active > 0 && start >= 0) (go active start)
  pure (HullIndex buckets active)

maybeGrowBuckets :: DenseMutableDcel s vertex directed undirected face -> Hull s -> HullIndex s -> ST s (HullIndex s)
maybeGrowBuckets dense hull hullIndex@(HullIndex buckets active) = do
  let !current = MUV.length buckets
  if active > 2 * current && current < hullBucketCapacity hull
    then rebuildBuckets dense hull active (min (hullBucketCapacity hull) (2 * current))
    else pure hullIndex

bucketFor :: Int -> Double -> Int
bucketFor count angle =
  min (count - 1) (max 0 (floor (angle * fromIntegral count * 0.25)))
{-# INLINE bucketFor #-}

ceilingBucketFor :: Int -> Double -> Int
ceilingBucketFor count angle =
  ceiling (angle * fromIntegral count * 0.25) `rem` count
{-# INLINE ceilingBucketFor #-}

-- | Install the authoritative outer edge whose angular segment contains each
-- bucket boundary in the half-open clockwise arc from @fromAngle@ to
-- @toAngle@. Adjacency remains solely in the DCEL; this is the derived section
-- needed to land a lookup near that ring.
writeBucketSegment
  :: MUV.MVector s Word32
  -> Double
  -> Double
  -> Int
  -> ST s ()
writeBucketSegment buckets fromAngle toAngle edge = do
  let !count = MUV.length buckets
      !fromBucket = ceilingBucketFor count fromAngle
      !toBucket = ceilingBucketFor count toAngle
      !packed = fromIntegral edge
  case compare fromBucket toBucket of
    LT -> MUV.set (MUV.unsafeSlice fromBucket (toBucket - fromBucket) buckets) packed
    GT -> do
      MUV.set (MUV.unsafeSlice fromBucket (count - fromBucket) buckets) packed
      MUV.set (MUV.unsafeSlice 0 toBucket buckets) packed
    EQ -> pure ()
{-# INLINE writeBucketSegment #-}

-- | Whether an outer edge's key orders at or before the stated query key,
-- settled field by field without materializing either key.
edgeAtMost
  :: DenseMutableDcel s vertex directed undirected face
  -> Hull s
  -> Int
  -> Double
  -> Double
  -> Double
  -> Int
  -> ST s Bool
edgeAtMost dense hull edge queryAngle queryX queryY tie = do
  angle <- readAngle hull edge
  case compare angle queryAngle of
    LT -> pure True
    GT -> pure False
    EQ -> do
      origin <- denseReadOrigin dense edge
      x <- denseReadPointX dense origin
      case compare x queryX of
        LT -> pure True
        GT -> pure False
        EQ -> do
          y <- denseReadPointY dense origin
          case compare y queryY of
            LT -> pure True
            GT -> pure False
            EQ -> pure (edge <= tie)

-- | Reconcile the derived angular index once after the local topology section
-- has glued. Replacing one outer edge by two adds one active edge; every closed
-- turn removes one. The final two edges cover the entire rewritten angular
-- arc, so their segments descend to the bucket view in one gluing step.
finishHullRewrite
  :: DenseMutableDcel s vertex directed undirected face
  -> Hull s
  -> HullIndex s
  -> Int
  -> Int
  -> Int
  -> ST s (HullIndex s)
finishHullRewrite dense hull (HullIndex buckets active) leftEdge rightEdge activeDelta = do
  leftAngle <- readAngle hull leftEdge
  middleAngle <- readAngle hull rightEdge
  afterRight <- denseReadNext dense rightEdge
  rightAngle <- readAngle hull afterRight
  writeBucketSegment buckets leftAngle middleAngle leftEdge
  writeBucketSegment buckets middleAngle rightAngle rightEdge
  maybeGrowBuckets dense hull (HullIndex buckets (active + activeDelta))
{-# INLINE finishHullRewrite #-}

-- | The outer edge whose key is the greatest key at or below the query: the
-- visible candidate the sweep inserts against. Bucket anchors land the walk
-- near the answer and the live outer cycle carries it the rest of the way.
hullCandidate
  :: DenseMutableDcel s vertex directed undirected face
  -> Hull s
  -> HullIndex s
  -> Double
  -> Double
  -> Double
  -> ST s Int
hullCandidate dense hull (HullIndex buckets active) queryAngle queryX queryY = do
  let !count = MUV.length buckets
      !bucket = bucketFor count queryAngle
  raw <- MUV.unsafeRead buckets bucket
  if raw == noOuterEdge
    then denseReadFaceEdge dense 0
    else adjustFromBoundary bucket (fromIntegral raw)
 where
  adjustFromBoundary !bucket !initial = do
    initialAtMost <- cyclicAtMost bucket initial
    if initialAtMost
      then advance active initial
      else retreat active initial
   where
    cyclicAtMost boundaryBucket edge = do
      angle <- readAngle hull edge
      if boundaryBucket == 0 && angle > queryAngle
        then pure True
        else edgeAtMost dense hull edge queryAngle queryX queryY maxBound

    advance !remaining !edge
      | remaining <= 0 = pure initial
      | otherwise = do
          following <- denseReadNext dense edge
          edgeAngle <- readAngle hull edge
          followingAngle <- readAngle hull following
          -- Only the bucket-zero anchor may precede the query by crossing the
          -- angular seam. Once the walk leaves that anchor, ordinary key order
          -- is authoritative; treating every high-angle edge as below a
          -- bucket-zero query walks straight past the answer and around the
          -- entire ring.
          let crossesSeam = followingAngle < edgeAngle
              seamPermitted = bucket == 0 && edgeAngle > queryAngle
          followingAtMost <- edgeAtMost dense hull following queryAngle queryX queryY maxBound
          if (not crossesSeam || seamPermitted) && followingAtMost
            then advance (remaining - 1) following
            else pure edge

    retreat !remaining !edge
      | remaining <= 0 = pure initial
      | otherwise = do
          previous <- denseReadPrevious dense edge
          previousAtMost <- cyclicAtMost bucket previous
          if previousAtMost
            then advance remaining previous
            else retreat (remaining - 1) previous

insertDeferred
  :: ReservedSweepCells s vertex directed undirected face
  -> SweepCellCursor
  -> DenseMutableDcel s vertex directed undirected face
  -> Hull s
  -> HullIndex s
  -> LegalizationArena s
  -> Int
  -> Int
  -> Int
  -> Int
  -> Double
  -> Double
  -> Double
  -> ST s (DeferredInsertion s)
insertDeferred reserved cursor dense hull hullIndex arena replacedEdge fromVertex toVertex vertex insertedX insertedY insertedAngle = do
  inserted <-
    insertOutsideHullAtEdge
      reserved
      cursor
      arena
      replacedEdge
      fromVertex
      toVertex
      vertex
  case inserted of
    SweepInsertionFailure failure -> pure (DeferredInsertionFailure failure)
    SweepInsertion firstOuterEdge lastOuterEdge initialFlips initialMaxDepth arenaAfterInsertion cursorAfterInsertion ->
      insertDeferredBetween
        firstOuterEdge
        lastOuterEdge
        initialFlips
        initialMaxDepth
        arenaAfterInsertion
        cursorAfterInsertion
 where
  insertDeferredBetween firstEdge lastEdge initialFlips initialMaxDepth arenaAfterInsertion cursorAfterInsertion = do
    -- The patch proves the new keys algebraically, so no key is rediscovered
    -- from the mesh: replacing outer edge a->b with a->v, v->b keeps the
    -- replaced edge's origin for the first spoke (its cached angle carries
    -- over) and starts the last spoke at the inserted vertex, whose angle is the
    -- one the candidate search was already given. The tie-break is each fresh
    -- edge id itself.
    replacedAngle <- readAngle hull replacedEdge
    -- Both spokes join the outer cycle before either is indexed, and a bucket
    -- rebuild during activation walks the live cycle: both angle slots must be
    -- initialized before the first activation can read them.
    MUV.unsafeWrite (hullAngleByEdge hull) firstEdge replacedAngle
    MUV.unsafeWrite (hullAngleByEdge hull) lastEdge insertedAngle
    -- Every turn this insertion closes legalizes together in one epoch. Closure
    -- never deletes an edge and never touches the outer cycle, so which turns
    -- close does not depend on when the interior is repaired; the fan above
    -- keeps its own drain because its candidates are oriented against the
    -- inserted vertex, and that star must still be intact when they are tested.
    ClosedHullSection left closuresLeft topLeft arenaAfterLeft cursorAfterLeft <- closeLeft firstEdge 0 0 arenaAfterInsertion cursorAfterInsertion
    ClosedHullSection right closuresRight topAll arenaAfterRight cursorAfterRight <- closeRight lastEdge 0 topLeft arenaAfterLeft cursorAfterLeft
    let !closures = closuresLeft + closuresRight
    nextHullIndex <- finishHullRewrite dense hull hullIndex left right (1 - closures)
    (!flips, !maxDepth, !finalArena) <-
      if topAll == 0
        then pure (0, 0, arenaAfterRight)
        else do
          LegalizationDrain drainedFlips drainedMaxDepth () drainedArena <-
            drainDenseUnconstrainedGenericLegalization dense arenaAfterRight topAll
          pure (drainedFlips, drainedMaxDepth, drainedArena)
    pure
      ( DeferredInsertionSuccess
          (initialFlips + flips)
          (max initialMaxDepth maxDepth)
          cursorAfterRight
          finalArena
          nextHullIndex
      )

  closeLeft !current !closures !top !sectionArena !sectionCursor = do
    left <- denseReadPrevious dense current
    close <- shouldCloseLeftTurn dense hull insertedAngle insertedX insertedY left current
    if not close
      then pure (ClosedHullSection current closures top sectionArena sectionCursor)
      else do
        leftAngle <- readAngle hull left
        (replacement, nextCursor) <- closeOuterTurnReserved reserved sectionCursor left
        -- Closing consecutive a->b, b->c into a->c keeps the first edge's
        -- origin, so the replacement inherits its angle; the tie is the fresh
        -- edge id. Both retired edges answer to the same left neighbour, the
        -- edge now preceding the replacement on the outer cycle. Seeding
        -- happens here, after the replacement's links exist.
        MUV.unsafeWrite (hullAngleByEdge hull) replacement leftAngle
        (nextArena, nextTop) <- seedGenericPairInArena sectionArena top left current
        closeLeft replacement (closures + 1) nextTop nextArena nextCursor

  closeRight !current !closures !top !sectionArena !sectionCursor = do
    right <- denseReadNext dense current
    close <- shouldCloseRightTurn dense hull insertedAngle insertedX insertedY current right
    if not close
      then pure (ClosedHullSection current closures top sectionArena sectionCursor)
      else do
        currentAngle <- readAngle hull current
        (replacement, nextCursor) <- closeOuterTurnReserved reserved sectionCursor current
        MUV.unsafeWrite (hullAngleByEdge hull) replacement currentAngle
        (nextArena, nextTop) <- seedGenericPairInArena sectionArena top current right
        closeRight replacement (closures + 1) nextTop nextArena nextCursor
{-# INLINE insertDeferred #-}

-- | Test the left-hand turn where the inserted point is the target of the
-- second edge. Adjacency is not rediscovered: 'closeLeft' obtained @first@
-- from @previous second@ in the same local section.
shouldCloseLeftTurn
  :: DenseMutableDcel s vertex directed undirected face
  -> Hull s
  -> Double
  -> Double
  -> Double
  -> Int
  -> Int
  -> ST s Bool
shouldCloseLeftTurn dense hull insertedAngle insertedX insertedY first second = do
  fromVertex <- denseReadOrigin dense first
  middleVertex <- denseReadOrigin dense (first `xor` 1)
  fromX <- denseReadPointX dense fromVertex
  fromY <- denseReadPointY dense fromVertex
  middleX <- denseReadPointX dense middleVertex
  middleY <- denseReadPointY dense middleVertex
  if orient2dWide fromX fromY middleX middleY insertedX insertedY /= GT
    then pure False
    else do
      -- The second edge begins at the middle vertex and stands on the outer
      -- cycle, so its cached key already is that vertex's pseudo-angle;
      -- same-ray is a slot read rather than a second angle.
      middleAngle <- readAngle hull second
      pure
        ( middleAngle == insertedAngle
            || acuteAtMiddle fromX fromY middleX middleY insertedX insertedY
        )
{-# INLINE shouldCloseLeftTurn #-}

-- | The symmetric right-hand test, where the inserted point is the first
-- edge's source. 'closeRight' obtained @second@ from @next first@, so this
-- section likewise consumes that adjacency proof instead of reading it again.
shouldCloseRightTurn
  :: DenseMutableDcel s vertex directed undirected face
  -> Hull s
  -> Double
  -> Double
  -> Double
  -> Int
  -> Int
  -> ST s Bool
shouldCloseRightTurn dense hull insertedAngle insertedX insertedY first second = do
  middleVertex <- denseReadOrigin dense (first `xor` 1)
  targetVertex <- denseReadOrigin dense (second `xor` 1)
  middleX <- denseReadPointX dense middleVertex
  middleY <- denseReadPointY dense middleVertex
  targetX <- denseReadPointX dense targetVertex
  targetY <- denseReadPointY dense targetVertex
  if orient2dWide insertedX insertedY middleX middleY targetX targetY /= GT
    then pure False
    else do
      middleAngle <- readAngle hull second
      pure
        ( middleAngle == insertedAngle
            || acuteAtMiddle insertedX insertedY middleX middleY targetX targetY
        )
{-# INLINE shouldCloseRightTurn #-}

-- The deferred turn test deliberately runs the widened Binary64 predicate
-- rather than the exact binary64 one; only the terminal Graham pass owns exact
-- convexity. This is the class's Double instance called by name, which is
-- what the boxed 'orient2d' on widened points resolved to.
orient2dWide :: Double -> Double -> Double -> Double -> Double -> Double -> Ordering
orient2dWide = orient2dCoordinates
{-# INLINE orient2dWide #-}

-- Spade's deferred-convexity rule is local: close the turn when the angle at
-- the shared hull vertex is strictly below 90 degrees. Requiring the entire
-- triangle to be acute leaves avoidable star-hull work for the terminal pass.
acuteAtMiddle :: Double -> Double -> Double -> Double -> Double -> Double -> Bool
acuteAtMiddle ax ay bx by cx cy =
  let !ux = ax - bx
      !uy = ay - by
      !vx = cx - bx
      !vy = cy - by
      !dot = ux * vx + uy * vy
      !scale = max 1 (ux * ux + uy * uy + vx * vx + vy * vy)
   in dot > 64 * encodeFloat 1 (-52) * scale
{-# INLINE acuteAtMiddle #-}

-- Clockwise pseudo-angle in [0,4), matching the orientation of the outer-face
-- cycle.
pseudoAngle :: Double -> Double -> Double -> Double -> Double
pseudoAngle centerX centerY x y
  | norm == 0 = 0
  | raw >= 4 = 0
  | otherwise = raw
 where
  !dx = x - centerX
  !dy = y - centerY
  !norm = abs dx + abs dy
  !projection = dx / norm
  !raw = if dy > 0 then 1 + projection else 3 - projection
{-# INLINE pseudoAngle #-}