moonlight-triangulation-0.1.0.0: src-build/Moonlight/Triangulation/Internal/CircleSweep.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TypeApplications #-}
module Moonlight.Triangulation.Internal.CircleSweep
( circleSweepInsert
) where
import Control.Monad (forM_, when)
import Control.Monad.ST (ST)
import Data.Bits (xor)
import Data.STRef
( STRef
, newSTRef
, readSTRef
, writeSTRef
)
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
( closeOuterTurn
, drainLegalization
, fixHullConvexity
, insertOutsideHullBetween
, LegalizationLaw (..)
, noStarVertex
, seedGenericEdges
)
import Moonlight.Triangulation.Internal.Mutable
import Moonlight.Triangulation.Internal.OperationState
( Counter (..)
, OperationState
, addCounter
, maxCounter
, readScratch
, 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)
, hullActiveCount :: !(MUV.MVector s Int)
, hullBuckets :: !(STRef s (MUV.MVector s Word32))
}
readActiveCount :: Hull s -> ST s Int
readActiveCount hull = MUV.unsafeRead (hullActiveCount hull) 0
{-# INLINE readActiveCount #-}
writeActiveCount :: Hull s -> Int -> ST s ()
writeActiveCount hull = MUV.unsafeWrite (hullActiveCount hull) 0
{-# INLINE writeActiveCount #-}
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
-> MUV.MVector s (Double, Double, Double, Word32)
-> ST s (Either BuildError Int)
circleSweepInsert mutable operation arena
| MUV.length arena == 0 = pure (Right 0)
| otherwise = do
Intro.sort arena
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 mutable
builtHull <- buildHull mutable operation centerX centerY
case builtHull of
Left failure -> pure (Left failure)
Right hull -> do
skipped <- MUV.new (ordered - seedCount)
inserted <- insertRemaining hull ordered seedCount skipped 0 0 0 0
case inserted of
Left failure -> pure (Left failure)
Right (!skippedCount, !fastCount, !flips, !maxDepth) -> do
-- 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
repaired <- fixHullConvexity @'ProbeOff mutable operation
case repaired of
Left failure -> pure (Left failure)
Right (!_closures, !terminalFlips, !terminalMaxDepth) -> do
addCounter operation CounterEdgeFlips terminalFlips
maxCounter operation CounterLegalizationMaxStack terminalMaxDepth
insertedSkipped <- insertSkipped skipped skippedCount 0
pure (seedCount <$ insertedSkipped)
where
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 !hull !ordered !index !skipped !skippedCount !fastCount !flips !maxDepth
| index >= ordered = pure (Right (skippedCount, fastCount, flips, maxDepth))
| otherwise = do
(_, queryXWide, queryYWide, raw) <- MUV.unsafeRead arena index
let !vertex = fromIntegral raw
queryX <- readPointX mutable vertex
queryY <- readPointY mutable vertex
let !queryAngle =
pseudoAngle (hullCenterX hull) (hullCenterY hull) queryXWide queryYWide
edge <- hullCandidate mutable hull queryAngle queryXWide queryYWide
fromVertex <- readOrigin mutable edge
toVertex <- readOrigin mutable (edge `xor` 1)
fromX <- readPointX mutable fromVertex
fromY <- readPointY mutable fromVertex
toX <- readPointX mutable toVertex
toY <- readPointY mutable toVertex
if orient2dCoordinates fromX fromY toX toY queryX queryY == GT
then do
deferred <- insertDeferred mutable operation hull edge vertex queryAngle
case deferred of
Left failure -> pure (Left failure)
Right (!_newClosures, !newFlips, !newMaxDepth) ->
insertRemaining
hull
ordered
(index + 1)
skipped
skippedCount
(fastCount + 1)
(flips + newFlips)
(max maxDepth newMaxDepth)
else do
MUV.unsafeWrite skipped skippedCount raw
insertRemaining hull 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
:: MutableDcel s vertex directed undirected face
-> ST s (Double, Double)
seedCentre mutable = do
(e0, e1, e2) <- faceEdges mutable 1
o0 <- readOrigin mutable e0
o1 <- readOrigin mutable e1
o2 <- readOrigin mutable e2
x0 <- readPointX mutable o0
y0 <- readPointY mutable o0
x1 <- readPointX mutable o1
y1 <- readPointY mutable o1
x2 <- readPointX mutable o2
y2 <- readPointY mutable 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
:: MutableDcel s vertex directed undirected face
-> OperationState s
-> Double
-> Double
-> ST s (Either BuildError (Hull s))
buildHull mutable operation centerX centerY = do
countResult <- collectOuterEdges mutable 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)
hullActiveCount <- MUV.replicate 1 count
initialBuckets <- MUV.replicate (initialBucketCount count capacity) noOuterEdge
hullBuckets <- newSTRef initialBuckets
let hull =
Hull
{ hullCenterX = centerX
, hullCenterY = centerY
, hullBucketCapacity = capacity
, hullAngleByEdge
, hullActiveCount
, hullBuckets
}
forM_ [0 .. count - 1] $ \index -> do
edge <- readScratch operation index
origin <- readOrigin mutable edge
x <- readPointX mutable origin
y <- readPointY mutable origin
MUV.unsafeWrite hullAngleByEdge edge (pseudoAngle centerX centerY x y)
installBucketMaximum mutable hull edge
pure (Right hull)
collectOuterEdges
:: MutableDcel s vertex directed undirected face
-> OperationState s
-> ST s (Either BuildError Int)
collectOuterEdges mutable operation = do
start <- readFaceEdge mutable 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 <- readNext mutable edge
go (remaining - 1) start following True (count + 1)
initialBucketCount :: Int -> Int -> Int
initialBucketCount active capacity =
min capacity (nextPowerOfTwo (max 8 ((active + 7) `quot` 8)))
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 :: MutableDcel s vertex directed undirected face -> Hull s -> Int -> ST s ()
rebuildBuckets mutable hull requested = do
let !count = max 1 (min (hullBucketCapacity hull) requested)
buckets <- MUV.replicate count noOuterEdge
writeSTRef (hullBuckets hull) buckets
active <- readActiveCount hull
start <- readFaceEdge mutable 0
let go !remaining !edge
| remaining <= 0 = pure ()
| otherwise = do
installBucketMaximum mutable hull edge
following <- readNext mutable edge
go (remaining - 1) following
when (active > 0 && start >= 0) (go active start)
maybeGrowBuckets :: MutableDcel s vertex directed undirected face -> Hull s -> ST s ()
maybeGrowBuckets mutable hull = do
active <- readActiveCount hull
buckets <- readSTRef (hullBuckets hull)
let !current = MUV.length buckets
when (active > 8 * current && current < hullBucketCapacity hull) $
rebuildBuckets mutable hull (min (hullBucketCapacity hull) (2 * current))
bucketFor :: Int -> Double -> Int
bucketFor count angle =
min (count - 1) (max 0 (floor (angle * fromIntegral count * 0.25)))
{-# INLINE bucketFor #-}
-- | Compare two outer edges by their origins' keys: cached angle first, then
-- the immutable origin coordinates, then the edge id. The coordinate reads
-- only happen on an exact angle tie, which is the same-ray case and no other.
compareEdgeKeys
:: MutableDcel s vertex directed undirected face
-> Hull s
-> Int
-> Int
-> ST s Ordering
compareEdgeKeys mutable hull left right = do
leftAngle <- readAngle hull left
rightAngle <- readAngle hull right
case compare leftAngle rightAngle of
LT -> pure LT
GT -> pure GT
EQ -> do
leftOrigin <- readOrigin mutable left
rightOrigin <- readOrigin mutable right
leftX <- readPointX mutable leftOrigin
rightX <- readPointX mutable rightOrigin
case compare leftX rightX of
LT -> pure LT
GT -> pure GT
EQ -> do
leftY <- readPointY mutable leftOrigin
rightY <- readPointY mutable rightOrigin
case compare leftY rightY of
LT -> pure LT
GT -> pure GT
EQ -> pure (compare left right)
-- | Whether an outer edge's key orders at or before the stated query key,
-- settled field by field without materializing either key.
edgeAtMost
:: MutableDcel s vertex directed undirected face
-> Hull s
-> Int
-> Double
-> Double
-> Double
-> Int
-> ST s Bool
edgeAtMost mutable 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 <- readOrigin mutable edge
x <- readPointX mutable origin
case compare x queryX of
LT -> pure True
GT -> pure False
EQ -> do
y <- readPointY mutable origin
case compare y queryY of
LT -> pure True
GT -> pure False
EQ -> pure (edge <= tie)
installBucketMaximum :: MutableDcel s vertex directed undirected face -> Hull s -> Int -> ST s ()
installBucketMaximum mutable hull edge = do
buckets <- readSTRef (hullBuckets hull)
angle <- readAngle hull edge
let !bucket = bucketFor (MUV.length buckets) angle
currentRaw <- MUV.unsafeRead buckets bucket
if currentRaw == noOuterEdge
then MUV.unsafeWrite buckets bucket (fromIntegral edge)
else do
verdict <- compareEdgeKeys mutable hull edge (fromIntegral currentRaw)
when (verdict == GT) (MUV.unsafeWrite buckets bucket (fromIntegral edge))
-- | Drop @edge@ from the bucket index. When it was its bucket's maximum, the
-- cyclic predecessor @left@ stands in provided it still belongs to the same
-- bucket — exact, because a bucket's edges form one contiguous arc of the
-- angular order. The caller states @left@ explicitly: the mesh has already
-- been mutated by the time the index is updated, so the retired edge no
-- longer knows its own neighbour.
removeBucketMaximum :: Hull s -> Int -> Int -> ST s ()
removeBucketMaximum hull edge left = do
buckets <- readSTRef (hullBuckets hull)
angle <- readAngle hull edge
let !bucket = bucketFor (MUV.length buckets) angle
current <- MUV.unsafeRead buckets bucket
when (current == fromIntegral edge) $ do
leftAngle <- readAngle hull left
if left /= edge && bucketFor (MUV.length buckets) leftAngle == bucket
then MUV.unsafeWrite buckets bucket (fromIntegral left)
else MUV.unsafeWrite buckets bucket noOuterEdge
retireEdge :: Hull s -> Int -> Int -> ST s ()
retireEdge hull edge left = do
removeBucketMaximum hull edge left
active <- readActiveCount hull
writeActiveCount hull (active - 1)
{-# INLINE retireEdge #-}
activateEdge :: MutableDcel s vertex directed undirected face -> Hull s -> Int -> ST s ()
activateEdge mutable hull edge = do
active <- readActiveCount hull
writeActiveCount hull (active + 1)
installBucketMaximum mutable hull edge
maybeGrowBuckets mutable hull
-- | 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
:: MutableDcel s vertex directed undirected face
-> Hull s
-> Double
-> Double
-> Double
-> ST s Int
hullCandidate mutable hull queryAngle queryX queryY = do
buckets <- readSTRef (hullBuckets hull)
let !count = MUV.length buckets
!bucket = bucketFor count queryAngle
raw <- MUV.unsafeRead buckets bucket
if raw == noOuterEdge
then previousNonEmpty buckets (if bucket == 0 then count - 1 else bucket - 1) count
else adjustWithinBucket buckets bucket (fromIntegral raw)
where
previousNonEmpty buckets !bucket !remaining
| remaining <= 0 = readFaceEdge mutable 0
| otherwise = do
raw <- MUV.unsafeRead buckets bucket
if raw /= noOuterEdge
then pure (fromIntegral raw)
else previousNonEmpty buckets (if bucket == 0 then MUV.length buckets - 1 else bucket - 1) (remaining - 1)
adjustWithinBucket buckets !bucket !initial = do
active <- readActiveCount hull
go active initial
where
go !remaining !edge
| remaining <= 0 = pure initial
| otherwise = do
atMost <- edgeAtMost mutable hull edge queryAngle queryX queryY maxBound
if atMost
then pure edge
else do
left <- readPrevious mutable edge
leftAngle <- readAngle hull left
if bucketFor (MUV.length buckets) leftAngle /= bucket
then pure left
else go (remaining - 1) left
insertDeferred
:: MutableDcel s vertex directed undirected face
-> OperationState s
-> Hull s
-> Int
-> Int
-> Double
-> ST s (Either BuildError (Int, Int, Int))
insertDeferred mutable operation hull replacedEdge vertex insertedAngle = do
inserted <- insertOutsideHullBetween @'ProbeOff mutable operation replacedEdge replacedEdge vertex
case inserted of
Left failure -> pure (Left failure)
Right (firstEdge, lastEdge) -> insertDeferredBetween firstEdge lastEdge
where
insertDeferredBetween firstEdge lastEdge = 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
leftOfReplaced <- readPrevious mutable firstEdge
retireEdge hull replacedEdge leftOfReplaced
-- 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
activateEdge mutable hull firstEdge
activateEdge mutable hull lastEdge
-- 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.
leftOutcome <- closeLeft firstEdge 0 0
case leftOutcome of
Left obstruction -> pure (Left obstruction)
Right (_left, closuresLeft, topLeft) -> do
rightOutcome <- closeRight lastEdge 0 topLeft
case rightOutcome of
Left obstruction -> pure (Left obstruction)
Right (_right, closuresRight, topAll) -> do
(flips, maxDepth) <- drainLegalization @'ProbeOff mutable operation topAll noStarVertex ValidMesh
pure (Right (closuresLeft + closuresRight, flips, maxDepth))
closeLeft !current !closures !top = do
left <- readPrevious mutable current
close <- shouldCloseTurn mutable hull insertedAngle left current
if not close
then pure (Right (current, closures, top))
else do
leftAngle <- readAngle hull left
closed <- closeOuterTurn mutable left
case closed of
Left obstruction -> pure (Left obstruction)
Right replacement -> do
-- 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.
leftOfReplacement <- readPrevious mutable replacement
retireEdge hull left leftOfReplacement
retireEdge hull current leftOfReplacement
MUV.unsafeWrite (hullAngleByEdge hull) replacement leftAngle
activateEdge mutable hull replacement
nextTop <- seedGenericEdges operation top [left, current]
closeLeft replacement (closures + 1) nextTop
closeRight !current !closures !top = do
right <- readNext mutable current
close <- shouldCloseTurn mutable hull insertedAngle current right
if not close
then pure (Right (current, closures, top))
else do
currentAngle <- readAngle hull current
closed <- closeOuterTurn mutable current
case closed of
Left obstruction -> pure (Left obstruction)
Right replacement -> do
leftOfReplacement <- readPrevious mutable replacement
retireEdge hull current leftOfReplacement
retireEdge hull right leftOfReplacement
MUV.unsafeWrite (hullAngleByEdge hull) replacement currentAngle
activateEdge mutable hull replacement
nextTop <- seedGenericEdges operation top [current, right]
closeRight replacement (closures + 1) nextTop
{-# INLINE insertDeferred #-}
shouldCloseTurn
:: MutableDcel s vertex directed undirected face
-> Hull s
-> Double
-> Int
-> Int
-> ST s Bool
shouldCloseTurn mutable hull insertedAngle first second = do
following <- readNext mutable first
if following /= second
then pure False
else do
fromVertex <- readOrigin mutable first
middleVertex <- readOrigin mutable (first `xor` 1)
targetVertex <- readOrigin mutable (second `xor` 1)
fromX <- readPointX mutable fromVertex
fromY <- readPointY mutable fromVertex
middleX <- readPointX mutable middleVertex
middleY <- readPointY mutable middleVertex
targetX <- readPointX mutable targetVertex
targetY <- readPointY mutable targetVertex
if orient2dWide fromX fromY middleX middleY targetX targetY /= 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 targetX targetY
)
{-# INLINE shouldCloseTurn #-}
-- 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 #-}