packages feed

geomancy-0.3.0.0: test/Spec.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}

import Geomancy
import Hedgehog

import Control.Monad (when)
import Data.Foldable (toList)
import GHC.Stack (withFrozenCallStack)
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Linear qualified
import Linear.Quaternion qualified
import System.Exit (exitFailure, exitSuccess)
import Text.Printf (printf)

import Geomancy.Mat4 (withRowMajor, (!*))
import Geomancy.Mat4 qualified
import Geomancy.Quaternion qualified
import Geomancy.Transform ((!.))
import Geomancy.Transform qualified as Transform
import Geomancy.Vulkan.Projection qualified as Projection
import Geomancy.Vulkan.View qualified as View

import Linear ((!*!))

main :: IO ()
main = do
  passed <- checkParallel discovered
  if passed then
    exitSuccess
  else
    exitFailure

-- | Enough tests to cover the principles
pattern PROP_TESTS :: TestLimit
pattern PROP_TESTS = 10_000

-- | Try harder to catch FP precision errors
pattern PROP_TESTS_BRUTAL :: TestLimit
pattern PROP_TESTS_BRUTAL = 10_000_000

prop_mat4_assoc :: Property
prop_mat4_assoc = withTests PROP_TESTS $ property do
  (p, p_) <- forAllTransform
  (v, v_) <- forAllTransform
  (m, m_) <- forAllTransform

  let
    pv'm = (p <> v) <> m
    p'vm = p <> (v <> m)
    delta' = nearlyEqualMat4 pv'm p'vm
  replicate 16 Nothing === delta'

  let
    mv_p = (m_ !*! v_) !*! p_
    m_vp = m_ !*! (v_ !*! p_)
    delta_ = nearlyEqualM44 mv_p m_vp
  replicate 16 Nothing === delta_

  -- Inter-library calculated values nearlyEqual
  replicate 16 Nothing === nearlyEqualM44 m_vp (toM44 p'vm)
  replicate 16 Nothing === nearlyEqualM44 mv_p (toM44 pv'm)

prop_mat4_order :: Property
prop_mat4_order = withTests 1 $ property do
  let
    cm = Geomancy.Mat4.colMajor @Geomancy.Mat4.Mat4
      0 1 2 3
      4 5 6 7
      8 9 0 1
      2 3 4 5
    rm = Geomancy.Mat4.rowMajor @Geomancy.Mat4.Mat4
      0 4 8 2
      1 5 9 3
      2 6 0 4
      3 7 1 5
  Geomancy.Mat4.toListColMajor2d cm === Geomancy.Mat4.toListColMajor2d rm

  let
    cmT = Geomancy.Mat4.transpose cm
    rmT = Geomancy.Mat4.transpose rm
  Geomancy.Mat4.toListColMajor2d cmT === Geomancy.Mat4.toListColMajor2d rmT

prop_transform_det :: Property
prop_transform_det = withTests 1 $ property do
  1     === Geomancy.Mat4.det Geomancy.Mat4.identity
  1     === Geomancy.Mat4.det (Transform.rotateY (pi/6))  -- area-preserving
  1     === Geomancy.Mat4.det (Transform.translate 1 2 3) -- area-preserving too
  0     === Geomancy.Mat4.det (Transform.scaleX 0)        -- space collapse, no inverse
  -1    === Geomancy.Mat4.det (Transform.scaleZ (-1))     -- flips the space
  8     === Geomancy.Mat4.det (Transform.scale 2)         -- stretches a little
  (1/8) === Geomancy.Mat4.det (Transform.scale (1/2))     -- stretches backwards

prop_projection_reverseDepthRH :: Property
prop_projection_reverseDepthRH = withTests 1 $ property do
  let fov90 = pi / 2
  let zNear = 0.1
  let Transform p = Projection.reverseDepthRH fov90 zNear 800 600
  annotateShow p
  let
    -- stating the expected in transposed order for testing
    Transform expected = Geomancy.Mat4.rowMajor
      0.7500 0.0000 0.0000 0.0000
      0.0000 1.0000 0.0000 0.0000
      0.0000 0.0000 0.0000 1.0000
      0.0000 0.0000  zNear 0.0000
  annotateShow expected
  let delta_ = nearlyEqualMat4 p expected
  replicate 16 Nothing === delta_

  let
    centerNearIn = vec4 0 0 zNear 1
    centerNearOut = p !* centerNearIn
    centerNearNDC = toNDC centerNearOut
  annotateShow (centerNearIn, centerNearOut)
  vec3 0 0 1 === centerNearNDC

prop_projection_orthoRH_identity :: Property
prop_projection_orthoRH_identity = withTests 1 $ property do
  let Transform pNative = Projection.orthoRH 0 1 2 2
  annotateShow pNative
  let delta_ = nearlyEqualMat4 pNative mempty
  replicate 16 Nothing === delta_

prop_projection_reverseDepthOrthoRH :: Property
prop_projection_reverseDepthOrthoRH = withTests 1 $ property do
  let t = Projection.reverseDepthOrthoRH 1 10 800 600
  annotateShow t

  vec4 0 0 1 1 === t !* vec4 0 0 1 1 -- center/near
  vec4 0 0 0 1 === t !* vec4 0 0 10 1 -- center/far
  vec4 1 1 0 1 === t !* vec4 400 300 10 1 -- edge/far
  vec4 (-1/400) (-1/300) 1 1 === t !* vec4 (-1) (-1) 1 1 -- step/near
  vec4 (1/400) (1/300) 0 1 === t !* vec4 1 1 10 1 -- step/far

toNDC :: Vec4 -> Vec3
toNDC v = withVec4 v \x y z w -> vec3 (x/w) (y/w) (z/w)

prop_view_lookAtRH_identity :: Property
prop_view_lookAtRH_identity = property do
  let alreadyThere = View.lookAtRH_ (vec3 0 0 0) (vec3 0 0 1)
  annotateShow alreadyThere

  p <- forAll genVec3

  p === Transform.apply p alreadyThere

xPos :: Vec3
xPos = vec3 1 0 0

xNeg :: Vec3
xNeg = vec3 (-1) 0 0

yPos :: Vec3
yPos = vec3 0 1 0

yNeg :: Vec3
yNeg = vec3 0 (-1) 0

zPos :: Vec3
zPos = vec3 0 0 1

zNeg :: Vec3
zNeg = vec3 0 0 (-1)

prop_transform_rotateX :: Property
prop_transform_rotateX = property do
  let angle = pi / 2
  let t = Transform.rotateX angle
  let q = Transform.rotateQ (Geomancy.Quaternion.axisAngle (vec3 1 0 0) angle)
  let yNegT = t !. zPos
  annotateShow yNegT
  let yNegQ = q !. zPos
  annotateShow yNegQ
  replicate 3 Nothing === nearlyEqualVec3 yNegT yNeg
  replicate 3 Nothing === nearlyEqualVec3 yNegQ yNeg

prop_transform_rotateY :: Property
prop_transform_rotateY = property do
  let angle = pi / 2
  let t = Transform.rotateY angle
  let q = Transform.rotateQ (Geomancy.Quaternion.axisAngle (vec3 0 1 0) angle)
  let xPosT = t !. zPos
  annotateShow xPosT
  let xPosQ = q !. zPos
  annotateShow xPosQ
  replicate 3 Nothing === nearlyEqualVec3 xPosT xPos
  replicate 3 Nothing === nearlyEqualVec3 xPosQ xPos

prop_transform_rotateZ :: Property
prop_transform_rotateZ = property do
  let angle = pi / 2
  let t = Transform.rotateZ angle
  let q = Transform.rotateQ (Geomancy.Quaternion.axisAngle (vec3 0 0 1) angle)
  let yPosT = t !. xPos
  annotateShow yPosT
  let yPosQ = q !. xPos
  annotateShow yPosQ
  replicate 3 Nothing === nearlyEqualVec3 yPosT yPos
  replicate 3 Nothing === nearlyEqualVec3 yPosQ yPos

prop_quaternion_ref :: Property
prop_quaternion_ref = property do
  annotate "Construction"
  (axis, angle) <- forAll $ (,) <$> genVec3 <*> genAngle
  let qg = Geomancy.Quaternion.axisAngle axis angle
  let ql = Linear.Quaternion.axisAngle (withVec3 axis Linear.V3) angle
  show qg === show (fromLQ ql)

  annotate "Rotation"
  p <- forAll genVec3
  let rpg = Geomancy.Quaternion.rotate qg p
  let rpl = Linear.Quaternion.rotate ql (withVec3 p Linear.V3)
  show rpg === show (fromV3 rpl)

  annotate "Multiplication"
  (axisB, angleB) <- forAll $ (,) <$> genVec3 <*> genAngle
  let qgB = Geomancy.Quaternion.axisAngle axisB angleB
  let qlB = Linear.Quaternion.axisAngle (withVec3 axisB Linear.V3) angleB
  let prodg = qg * qgB
  let prodl = ql * qlB
  show prodg === show (fromLQ prodl)

prop_quaternion_associativity :: Property
prop_quaternion_associativity = property do
  (a, b, c) <- forAll $ (,,)
    <$> (Geomancy.Quaternion.axisAngle <$> genVec3 <*> genAngle)
    <*> (Geomancy.Quaternion.axisAngle <$> genVec3 <*> genAngle)
    <*> (Geomancy.Quaternion.axisAngle <$> genVec3 <*> genAngle)
  let ab'c = (a * b) * c
  annotateShow ab'c
  let a'bc = a * (b * c)
  annotateShow a'bc
  replicate 4 Nothing === nearlyEqualQ ab'c a'bc

prop_quaternion_intrinsic :: Property
prop_quaternion_intrinsic = withTests 1 $ property do
  let yaw = pi / 2
  let pitch = pi / 2
  let roll = 0
  let q = Geomancy.Quaternion.intrinsic roll pitch yaw
  let forward' = Geomancy.Quaternion.rotate q zPos
  let right' = Geomancy.Quaternion.rotate q xPos
  let up' = Geomancy.Quaternion.rotate q yNeg
  replicate 3 Nothing === nearlyEqualVec3 yNeg forward'
  replicate 3 Nothing === nearlyEqualVec3 zNeg right'
  replicate 3 Nothing === nearlyEqualVec3 xNeg up'

prop_quaternion_extrinsic :: Property
prop_quaternion_extrinsic = withTests 1 $ property do
  let heading = pi / 2
  let elevation = pi / 2
  let tilt = 0
  let q = Geomancy.Quaternion.extrinsic heading elevation tilt
  let forward' = Geomancy.Quaternion.rotate q zPos
  let right' = Geomancy.Quaternion.rotate q xPos
  let up' = Geomancy.Quaternion.rotate q yNeg
  replicate 3 Nothing === nearlyEqualVec3 xPos forward'
  replicate 3 Nothing === nearlyEqualVec3 yPos right'
  replicate 3 Nothing === nearlyEqualVec3 zNeg up'

prop_quaternion_intrinsic_ref :: Property
prop_quaternion_intrinsic_ref = property do
  (roll, pitch, yaw) <- forAll $ (,,) <$> genAngle <*> genAngle <*> genAngle
  let fused = Geomancy.Quaternion.intrinsic roll pitch yaw
  annotateShow fused
  let composed = intrinsicComposed roll pitch yaw
  annotateShow composed
  let viaFused = Geomancy.Quaternion.rotate fused zPos
  annotateShow viaFused
  let viaComposed = Geomancy.Quaternion.rotate composed zPos
  replicate 3 Nothing === nearlyEqualVec3 viaFused viaComposed

intrinsicComposed :: Float -> Float -> Float -> Quaternion
intrinsicComposed roll pitch yaw =
  Geomancy.Quaternion.axisAngle (vec3 0 1 0) yaw *
  Geomancy.Quaternion.axisAngle (vec3 1 0 0) pitch *
  Geomancy.Quaternion.axisAngle (vec3 0 0 1) roll

prop_transform_node :: Property
prop_transform_node = withTests 1 $ property do
  let t = vec3 2 3 5
  let r = intrinsicComposed 0 0 (pi/7)
  let s = 11
  let dps = Transform.node t r s
  let node = trs t r s
  let v = vec3 0 0 1
  replicate 3 Nothing === nearlyEqualVec3 (dps !. v) (node !. v)

trs :: Vec3 -> Quaternion -> Vec3 -> Transform
trs t r s = mconcat
  [ Transform.translateV t
  , Transform.rotateQ r
  , Transform.scaleV s
  ]

prop_transform_order :: Property
prop_transform_order = withTests 1 $ property do
  let parent = Transform.node (vec3 4 5 6) (intrinsicComposed 0 0 (pi/6)) 2
  let local = Transform.node (vec3 7 8 9) (intrinsicComposed 0 (pi/6) 0) (1/3)
  let precomp = parent <> local !. vec3 0 0 1
  let nocomp = parent !. local !. vec3 0 0 1

  replicate 3 Nothing === nearlyEqualVec3 precomp nocomp

forAllTransform :: PropertyT IO (Geomancy.Mat4.Mat4, Linear.M44 Float)
forAllTransform = withFrozenCallStack do
  (_name, Transform g) <- forAllWith fst genTransform
  pure (g, toM44 g)

genVec3 :: Gen Vec3
genVec3 =
  vec3
    <$> Gen.float (Range.linearFracFrom 0.0 (-1e6) 1e6)
    <*> Gen.float (Range.linearFracFrom 0.0 (-1e6) 1e6)
    <*> Gen.float (Range.linearFracFrom 0.0 (-1e6) 1e6)

genAngle :: Gen Float
genAngle = Gen.float (Range.linearFracFrom 0.0 (-8*pi) (8*pi))

genTransform :: Gen ([Char], Transform) -- TODO: cross-check with Linear
genTransform = Gen.choice
  [ genIdentity
  , genTranslate
  , genRotate
  , genScale
  ]
  where
    genIdentity = pure ("identity", mempty)

    genTranslate = do
      x <- Gen.float (Range.linearFracFrom 0.0 (-1e6) 1e6)
      y <- Gen.float (Range.linearFracFrom 0.0 (-1e6) 1e6)
      z <- Gen.float (Range.linearFracFrom 0.0 (-1e6) 1e6)
      when (abs x == 0 && abs y == 0 && abs z == 0) Gen.discard
      pure
        ( printf "translate %0.4f %0.4f %0.4f" x y z
        , Transform.translate x y z
        )

    genRotate = do
      (name, axis) <- Gen.element
        [ ("rotate/x", Transform.rotateX)
        , ("rotate/y", Transform.rotateY)
        , ("rotate/z", Transform.rotateZ)
        ]
      angle <- genAngle
      when (abs angle == 0) Gen.discard
      pure
        ( printf "%s %0.4f" name angle
        , axis angle
        )

    genScale = do
      x <- Gen.float (Range.linearFracFrom 1.0 1e-6 1e6)
      y <- Gen.float (Range.linearFracFrom 1.0 1e-6 1e6)
      z <- Gen.float (Range.linearFracFrom 1.0 1e-6 1e6)
      when (abs x == 1 && abs y == 1 && abs z == 1) Gen.discard
      pure
        ( printf "scale %0.4f %0.4f %0.4f" x y z
        , Transform.scale3 x y z
        )

fromV3 :: Linear.V3 Float -> Vec3
fromV3 (Linear.V3 x y z) = Geomancy.vec3 x y z

fromLQ :: Linear.Quaternion.Quaternion Float -> Geomancy.Quaternion
fromLQ (Linear.Quaternion.Quaternion s (Linear.V3 x y z)) = Geomancy.quaternion s x y z

toM44 :: Geomancy.Mat4 -> Linear.M44 Float
toM44 mat4 =
  withRowMajor mat4
    \ m00 m10 m20 m30
      m01 m11 m21 m31
      m02 m12 m22 m32
      m03 m13 m23 m33 ->
        Linear.V4
          (Linear.V4 m00 m10 m20 m30)
          (Linear.V4 m01 m11 m21 m31)
          (Linear.V4 m02 m12 m22 m32)
          (Linear.V4 m03 m13 m23 m33)

nearlyEqualMat4 :: Geomancy.Mat4.Mat4 -> Geomancy.Mat4.Mat4 -> [Maybe Oops]
nearlyEqualMat4 a b = Geomancy.Mat4.zipWith nearlyEqual a b

nearlyEqualM44 :: Linear.M44 Float -> Linear.M44 Float -> [Maybe Oops]
nearlyEqualM44 a b = zipWith nearlyEqual (concatMap toList a) (concatMap toList b)

nearlyEqualVec3 :: Vec3 -> Vec3 -> [Maybe Oops]
nearlyEqualVec3 a b =
  withVec3 a \ax ay az ->
  withVec3 b \bx by bz ->
    [ nearlyEqual ax bx
    , nearlyEqual ay by
    , nearlyEqual az bz
    ]

nearlyEqualQ :: Geomancy.Quaternion -> Geomancy.Quaternion -> [Maybe Oops]
nearlyEqualQ a b =
  Geomancy.withQuaternion a \as ax ay az ->
    Geomancy.withQuaternion b \bs bx by bz ->
      [ nearlyEqual as bs
      , nearlyEqual ax bx
      , nearlyEqual ay by
      , nearlyEqual az bz
      ]

nearlyEqual :: Float -> Float -> Maybe Oops
nearlyEqual lhs rhs =
  if lhs == rhs || absDiff < 1e-4 || relDiff < 1e-4 then
    Nothing
  else
    Just Oops{..}
  where
    absDiff = abs $ lhs - rhs
    relDiff = absDiff / (abs lhs + abs rhs)
    -- fltEps = 1.19209290e-07
    -- fltMin = 1.175494e-38

data Oops = Oops {lhs :: Float, rhs :: Float, absDiff :: Float, relDiff :: Float}
  deriving (Eq, Show)

discovered :: Group
discovered = $$(discover)