packages feed

moonlight-planar-1.1.0.0: test/native/Moonlight/Planar/ValidationSpec.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}

-- | Structural and Delaunay rejection against explicit mesh corruptions.
module Moonlight.Planar.ValidationSpec
  ( tests
  ) where

import Control.Monad ( unless, when )
import Data.Word ( Word32 )
import Moonlight.Planar.Dcel ( faceDirectedEdges, numDirectedEdges, numVertices )
import Moonlight.Planar.Handles.Iterators.FixedIterators ( innerFaces )
import Moonlight.Planar.Internal.HandleDefs ( DirectedEdgeId(DirectedEdgeId), FaceId(unFaceId) )
import Moonlight.Planar.Internal.Paged ( Paged, fromVector, toVector )
import Moonlight.Planar.Internal.Validation ( validateTopologyClosure )
import Moonlight.Planar.Internal.Types (PlanarIncidenceError (..))
import Moonlight.Planar.MeshFixtures ( requirePointBuild )
import Moonlight.Planar.Point (Point(Point))
import Moonlight.Planar.Types (ConstraintMode(Unconstrained), InvariantViolation(LocallyIllegalDelaunayEdge,
  CoordinatePlaneLengthMismatch, InnerFaceNotCounterClockwise, IncidenceViolation), BuildResult(buildTriangulation), Triangulation)
import Moonlight.Planar.Validation ( validateDelaunay, validateTopology )
import Support ( assertEqual, assertValid )
import qualified Data.IntSet as IntSet
import Moonlight.Planar.Internal.Representation qualified as Internal
import qualified Data.Vector.Unboxed as U


tests :: IO ()
tests =
  sequence_
    [ testValidationRejectsCorruptedMeshes
    , testRejectsMismatchedTwinEndpoints
    ]

type NativeMesh = Triangulation 'Unconstrained (Point) () () ()

-- Both face cycles, all representatives and Euler hold. Only the paired
-- endpoints disagree: XOR involution alone is not the twin law.
testRejectsMismatchedTwinEndpoints :: IO ()
testRejectsMismatchedTwinEndpoints = do
  built <- requirePointBuild "mismatched twin fixture" [Point 0 0, Point 1 0, Point 2 1]
  let mesh :: NativeMesh
      mesh = (buildTriangulation built)
        { Internal.triHalfTopology = fromVector 0 (U.fromList
            [0,2,4,1, 0,3,5,0, 1,4,0,1, 2,5,1,0, 2,0,2,1, 1,1,3,0])
        , Internal.triVertexOut = fromVector 0 (U.fromList [0,2,4])
        , Internal.triFaceEdge = fromVector 0 (U.fromList [1,0])
        }
  assertEqual "mismatched twin endpoints rejected"
    [IncidenceViolation (IncidenceEdgeEndpointMismatch (DirectedEdgeId 0))]
    (validateTopology mesh)
  unless (any isEndpointMismatch (validateTopologyClosure (IntSet.singleton 1) IntSet.empty mesh)) $
    fail "local closure missed the shared endpoint law"
  let inconsistentFace :: NativeMesh
      inconsistentFace = mesh
        { Internal.triHalfTopology = fromVector 0 (U.fromList
            [0,2,4,1, 1,5,3,0, 1,4,0,0, 2,1,5,0, 2,0,2,1, 0,3,1,0]) }
  assertEqual "face continuity rejected despite triangular next cycles"
    [IncidenceViolation (IncidenceEdgeLinkMismatch (DirectedEdgeId 0))]
    (validateTopology inconsistentFace)
 where
  isEndpointMismatch (IncidenceViolation IncidenceEdgeEndpointMismatch {}) = True
  isEndpointMismatch _ = False

-- | A named corruption of a mesh that was valid one line earlier, paired with
-- the violation it must provoke. Validation that has never been made to fail is
-- evidence only that it ran.
data Corruption = Corruption
  { corruptionName :: String
  , corruptMesh :: NativeMesh -> NativeMesh
  , provokes :: InvariantViolation -> Bool
  }

-- Sheared so that no four sites are cocircular: on a square grid the diagonal
-- of every cell is a free choice, and a fixture that admits two answers cannot
-- witness a wrong one.
corruptionFixture :: [Point]
corruptionFixture =
  [ Point (fromIntegral column + 0.25 * fromIntegral row) (1.3 * fromIntegral row)
  | column <- [0 .. 3 :: Int]
  , row <- [0 .. 3 :: Int]
  ]

rewritePlane :: (U.Unbox a, Num a) => (U.Vector a -> U.Vector a) -> Paged a -> Paged a
rewritePlane edit = fromVector 0 . edit . toVector

slot :: U.Unbox a => Int -> a -> U.Vector a -> U.Vector a
slot at value = (U.// [(at, value)])

-- Past the end of every plane in the fixture, and far from the sentinels the
-- packed representation reserves for absence.
beyond :: NativeMesh -> Word32
beyond mesh = fromIntegral (numDirectedEdges mesh + numVertices mesh + 64)

onTopology :: (NativeMesh -> U.Vector Word32 -> U.Vector Word32) -> NativeMesh -> NativeMesh
onTopology edit mesh =
  mesh {Internal.triHalfTopology = rewritePlane (edit mesh) (Internal.triHalfTopology mesh)}

-- The half-edge plane has stride four — origin, next, previous, face — so the
-- first four entries below are one surgery distinguished only by which field
-- the out-of-range index lands on.
structuralCorruptions :: [Corruption]
structuralCorruptions =
  [ Corruption
      "edge origin names an absent vertex"
      (onTopology (slot 0 . beyond))
      (\case IncidenceViolation IncidenceVertexOriginInvalid {} -> True; _ -> False)
  , Corruption
      "edge next names an absent edge"
      (onTopology (slot 1 . beyond))
      (\case IncidenceViolation IncidenceEdgeLinksInvalid {} -> True; _ -> False)
  , Corruption
      "edge previous names an absent edge"
      (onTopology (slot 2 . beyond))
      (\case IncidenceViolation IncidenceEdgeLinksInvalid {} -> True; _ -> False)
  , Corruption
      "edge face names an absent face"
      (onTopology (slot 3 . beyond))
      (\case IncidenceViolation IncidenceEdgeFaceInvalid {} -> True; _ -> False)
  , Corruption
      "vertex outgoing names an absent edge"
      ( \mesh ->
          mesh
            { Internal.triVertexOut =
                rewritePlane (slot 0 (beyond mesh)) (Internal.triVertexOut mesh)
            }
      )
      (\case IncidenceViolation IncidenceVertexRootInvalid {} -> True; _ -> False)
  , Corruption
      "a coordinate plane is one short"
      (\mesh -> mesh {Internal.triPointX = rewritePlane U.init (Internal.triPointX mesh)})
      (\case CoordinatePlaneLengthMismatch {} -> True; _ -> False)
  , Corruption
      "every inner face wound clockwise"
      (\mesh -> mesh {Internal.triPointY = rewritePlane (U.map negate) (Internal.triPointY mesh)})
      (\case InnerFaceNotCounterClockwise {} -> True; _ -> False)
  ]

-- Structural corruption is caught before geometry is read, so the empty-circle
-- law needs a mesh that stays well formed and merely stops being Delaunay.
delaunayCorruptions :: [Corruption]
delaunayCorruptions =
  [ Corruption
      "one site dragged through its neighbours' circumcircles"
      (\mesh -> mesh {Internal.triPointX = rewritePlane (slot 5 40) (Internal.triPointX mesh)})
      (\case LocallyIllegalDelaunayEdge {} -> True; _ -> False)
  ]

-- A surgery that changed nothing would report the oracle as unarmed when in
-- truth it was never asked anything, so the mutant must differ before its
-- rejection means a thing.
assertRejects
  :: String -> (NativeMesh -> [InvariantViolation]) -> NativeMesh -> Corruption -> IO ()
assertRejects oracle check pristine corruption = do
  let mutant = corruptMesh corruption pristine
      label = oracle <> " / " <> corruptionName corruption
      reported = check mutant
  when (mutant == pristine) $ fail (label <> ": the surgery changed nothing")
  unless (any (provokes corruption) reported) $
    fail (label <> ": admitted, reporting " <> show reported)

testValidationRejectsCorruptedMeshes :: IO ()
testValidationRejectsCorruptedMeshes = do
  built <- requirePointBuild "corruption fixture" corruptionFixture
  let pristine = buildTriangulation built
  assertValid "corruption fixture" pristine
  mapM_ (assertRejects "topology" validateTopology pristine) structuralCorruptions
  mapM_ (assertRejects "delaunay" validateDelaunay pristine) delaunayCorruptions
  case innerFaces pristine of
    [] -> fail "local validation fixture has no inner face"
    witnessFace : _ ->
      case faceDirectedEdges pristine witnessFace of
        [] -> fail "local validation fixture has no face edge"
        DirectedEdgeId rawEdge : _ -> do
          let admitted = IntSet.singleton (fromIntegral (unFaceId witnessFace))
              corrupted
                :: NativeMesh
              corrupted =
                pristine
                  { Internal.triHalfTopology =
                      rewritePlane
                        (slot (4 * fromIntegral rawEdge + 1) rawEdge)
                        (Internal.triHalfTopology pristine)
                  }
          assertEqual
            "selected validation accepts valid closure"
            []
            (validateTopologyClosure admitted IntSet.empty pristine)
          unless (not (null (validateTopology corrupted))) $
            fail "global validation missed the locally corrupt face closure"
          unless (not (null (validateTopologyClosure admitted IntSet.empty corrupted))) $
            fail "selected validation missed a locally corrupt face closure"