Box2D-0.1.0.0: test/Spec.hs
{-# LANGUAGE ForeignFunctionInterface #-}
{-# LANGUAGE PatternSynonyms #-}
module Main (main) where
import Control.Concurrent (getNumCapabilities, yield)
import Control.Exception (ErrorCall (..), bracket, evaluate, try)
import Control.Monad (forM_, replicateM_, unless, when)
import Data.IORef
import Data.Vector.Storable qualified as VS
import Foreign
import Foreign.C.Types (CBool (..))
import GHC.Clock (getMonotonicTime)
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.Runners (NumThreads (..))
import Box2D.Base qualified as Base
import Box2D.Body qualified as Body
import Box2D.Callbacks qualified as CB
import Box2D.Chain qualified as Chain
import Box2D.Collision qualified as Collision
import Box2D.Contact qualified as Contact
import Box2D.DistanceJoint qualified as DistanceJoint
import Box2D.DynamicTree qualified as DynamicTree
import Box2D.Events qualified as Events
import Box2D.Extra (withShapeProxy)
import Box2D.Id (BodyId (..), ChainId, ContactId (..), ShapeId (..), WorldId (..), nullBodyId, nullContactId, nullWorldId)
import Box2D.Joint qualified as Joint
import Box2D.MathFunctions
import Box2D.MathTypes
import Box2D.Mover qualified as Mover
import Box2D.Shape qualified as Shape
import Box2D.UserData qualified as UD
import Box2D.World qualified as World
import Box2D.Body (BodyDef (..), defaultBodyDef, pattern DynamicBody, pattern StaticBody)
import Box2D.Collision (CollisionPlane (..), Manifold (..), PlaneResult (..))
import Box2D.Contact (ContactData (..))
import Box2D.DebugDraw (DebugDraw (..), defaultDebugDraw)
import Box2D.Distance (SimplexCache (..))
import Box2D.DistanceJoint (DistanceJointDef (..), defaultDistanceJointDef)
import Box2D.FilterJoint (defaultFilterJointDef)
import Box2D.Geometry (Capsule (..), CastOutput (..), Circle (..), Hull (..), MassData (..), Polygon (..), RayCastInput (..), Segment (..), ShapeProxy (..))
import Box2D.Joint (JointDef (..), pattern DistanceJoint)
import Box2D.MotorJoint (defaultMotorJointDef)
import Box2D.PrismaticJoint (defaultPrismaticJointDef)
import Box2D.RevoluteJoint (defaultRevoluteJointDef)
import Box2D.Shape (QueryFilter (..), ShapeDef (..), defaultFilter, defaultQueryFilter, defaultShapeDef, defaultSurfaceMaterial, pattern CapsuleShape, pattern ChainSegmentShape, pattern CircleShape, pattern PolygonShape, pattern SegmentShape)
import Box2D.WeldJoint (defaultWeldJointDef)
import Box2D.WheelJoint (defaultWheelJointDef)
import Box2D.World (BodyMoveEvent (..), ContactBeginTouchEvent (..), ContactEndTouchEvent (..), ContactHitEvent (..), ExplosionDef (..), JointEvent (..), RayResult (..), SensorBeginTouchEvent (..), SensorEndTouchEvent (..), WorldDef (..), defaultExplosionDef, defaultWorldDef)
main :: IO ()
-- Box2D keeps a global registry of worlds and a world is single-threaded
-- state, so the suite must run sequentially rather than tasty's default
-- parallel execution.
main = defaultMain (localOption (NumThreads 1) tests)
tests :: TestTree
tests =
testGroup
"Box2D"
[ testGroup "Base" baseTests
, testGroup "MathTypes" mathTypesTests
, testGroup "MathFunctions" mathFunctionsTests
, testGroup "Id" idTests
, testGroup "Types" typesTests
, testGroup "Simulation" simulationTests
, testGroup "Generated breadth" generatedTests
, testGroup "Joints" jointTests
, testGroup "Events" eventTests
, testGroup "Queries" queryTests
, testGroup "Polygons and chains" polygonTests
, testGroup "Proxies, contacts and the mover" moverTests
, testGroup "Callbacks" callbackTests
]
-- | Poke a value and read it back, exercising the 'Storable' instance.
roundTrip :: (Storable a) => a -> IO a
roundTrip x = alloca $ \p -> poke p x >> peek p
approx :: Float -> Float -> Assertion
approx expected actual =
assertBool
("expected " <> show expected <> " but got " <> show actual)
(abs (expected - actual) < 1e-5)
baseTests :: [TestTree]
baseTests =
[ testCase "getVersion is 3.2.0" $ do
v <- Base.getVersion
v.major @?= 3
v.minor @?= 2
v.revision @?= 0
, testCase "getByteCount is non-negative" $ do
n <- Base.getByteCount
assertBool "byte count >= 0" (n >= 0)
, testCase "isDoublePrecision matches the size of Pos" $ do
dp <- Base.isDoublePrecision
sizeOf (undefined :: Pos) @?= if dp then 16 else 8
]
mathTypesTests :: [TestTree]
mathTypesTests =
[ testCase "Pos and WorldTransform round-trip through Storable" $ do
roundTrip (Pos 1.5 (-2.5)) >>= (@?= Pos 1.5 (-2.5))
let xf = WorldTransform (Pos 3 4) (Rot 0 1)
roundTrip xf >>= (@?= xf)
, testCase "world position helpers cross the Pos/Vec2 boundary" $ do
let xf = WorldTransform (Pos 10 20) (Rot 0 1)
toVec2 (toPos (Vec2 1.5 2.5)) @?= Vec2 1.5 2.5
subPos (Pos 5 7) (Pos 2 3) @?= Vec2 3 4
offsetPos (Pos 1 2) (Vec2 3 4) @?= Pos 4 6
transformWorldPoint xf (Vec2 1 0) @?= Pos 10 21
invTransformWorldPoint xf (Pos 10 21) @?= Vec2 1 0
toRelativeTransform xf (Pos 9 18) @?= Transform (Vec2 1 2) (Rot 0 1)
makeWorldTransform (Transform (Vec2 1 2) (Rot 0 1)) @?= WorldTransform (Pos 1 2) (Rot 0 1)
, testCase "type sizes match the C ABI" $ do
sizeOf (Vec2 0 0) @?= 8
sizeOf (Rot 1 0) @?= 8
sizeOf (Transform vec2Zero rotIdentity) @?= 16
sizeOf (Mat22 vec2Zero vec2Zero) @?= 16
sizeOf (AABB vec2Zero vec2Zero) @?= 16
sizeOf (Plane vec2Zero 0) @?= 12
, testCase "Vec2 round-trips" $
roundTrip (Vec2 1 2) >>= (@?= Vec2 1 2)
, testCase "Transform round-trips" $ do
let t = Transform (Vec2 1 2) (Rot 0.6 0.8)
roundTrip t >>= (@?= t)
, testCase "AABB round-trips" $ do
let a = AABB (Vec2 (-1) (-2)) (Vec2 3 4)
roundTrip a >>= (@?= a)
]
mathFunctionsTests :: [TestTree]
mathFunctionsTests =
[ testCase "finite vector is valid" $
isValidVec2 (Vec2 1 2) >>= (@?= True)
, testCase "NaN vector is invalid" $
isValidVec2 (Vec2 (0 / 0) 0) >>= (@?= False)
, testCase "infinite vector is invalid" $
isValidVec2 (Vec2 (1 / 0) 0) >>= (@?= False)
, testCase "identity rotation is valid" $
isValidRot rotIdentity >>= (@?= True)
, testCase "identity transform is valid" $
isValidTransform transformIdentity >>= (@?= True)
, testCase "computeCosSin 0 = (1, 0)" $ do
CosSin c s <- computeCosSin 0
approx 1 c
approx 0 s
, testCase "makeRot 0 is the identity" $ do
Rot c s <- makeRot 0
approx 1 c
approx 0 s
, testCase "rotGetAngle recovers the angle" $ do
approx 1.2 (rotGetAngle (Rot (cos 1.2) (sin 1.2)))
approx 0 . rotGetAngle =<< makeRot 0
-- makeRot uses an approximate cos/sin, so its round-trip is loose
r <- makeRot 1.2
let a = rotGetAngle r
assertBool ("~1.2 through makeRot, got " <> show a) (abs (a - 1.2) < 5e-3)
, testCase "makeRot pi/2 is a quarter turn" $ do
Rot c s <- makeRot (pi / 2)
approx 0 c
approx 1 s
]
idTests :: [TestTree]
idTests =
[ testCase "id sizes match the C ABI" $ do
sizeOf nullWorldId @?= 4
sizeOf nullBodyId @?= 8
sizeOf nullContactId @?= 12
, testCase "WorldId round-trips" $
roundTrip (WorldId 0x50009) >>= (@?= WorldId 0x50009)
, testCase "BodyId round-trips" $
roundTrip (BodyId 0x1122334455667788) >>= (@?= BodyId 0x1122334455667788)
, testCase "ContactId round-trips" $
roundTrip (ContactId 1 2 4) >>= (@?= ContactId 1 2 4)
]
typesTests :: [TestTree]
typesTests =
[ testCase "default world def has expected fields" $ do
d <- defaultWorldDef
d.gravity @?= Vec2 0 (-10)
d.contactHertz @?= 30
d.enableSleep @?= 1
assertBool "internalValue cookie is set" (d.internalValue /= 0)
, testCase "default world def round-trips" $ do
d <- defaultWorldDef
roundTrip d >>= (@?= d)
, testCase "default body def has expected fields" $ do
d <- defaultBodyDef
d.type_ @?= StaticBody
d.rotation @?= rotIdentity
d.gravityScale @?= 1
assertBool "internalValue cookie is set" (d.internalValue /= 0)
, testCase "default body def round-trips" $ do
d <- defaultBodyDef
roundTrip d >>= (@?= d)
, testCase "default shape def has expected fields" $ do
d <- defaultShapeDef
d.density @?= 1
d.updateBodyMass @?= 1
assertBool "internalValue cookie is set" (d.internalValue /= 0)
, testCase "default shape def round-trips" $ do
d <- defaultShapeDef
roundTrip d >>= (@?= d)
, testCase "default filter round-trips" $ do
f <- defaultFilter
roundTrip f >>= (@?= f)
, testCase "default surface material round-trips" $ do
m <- defaultSurfaceMaterial
roundTrip m >>= (@?= m)
, testCase "Circle round-trips" $ do
let c = Circle (Vec2 1 2) 0.5
roundTrip c >>= (@?= c)
, testCase "Capsule round-trips" $ do
let c = Capsule (Vec2 0 0) (Vec2 0 1) 0.25
roundTrip c >>= (@?= c)
, testCase "Segment round-trips" $ do
let s = Segment (Vec2 (-1) 0) (Vec2 1 0)
roundTrip s >>= (@?= s)
, testCase "shape types case as patterns" $
withWorld (Vec2 0 0) $ \w -> do
bd <- defaultBodyDef
b <- Body.create w bd
sd <- defaultShapeDef
sh <- Shape.createCircle b sd (Circle vec2Zero 0.5)
ty <- Shape.getType sh
let name = case ty of
CircleShape -> "circle"
CapsuleShape -> "capsule"
SegmentShape -> "segment"
PolygonShape -> "polygon"
ChainSegmentShape -> "chain segment"
_ -> "unknown"
name @?= ("circle" :: String)
, testCase "a warm SimplexCache round-trips its indices" $ do
let cache = SimplexCache (VS.fromList [1, 2, 3]) (VS.fromList [4, 5, 6])
roundTrip cache >>= (@?= cache)
, testCase "a SimplexCache count follows its shorter index array" $
roundTrip (SimplexCache (VS.fromList [1, 2]) (VS.fromList [3]))
>>= (@?= SimplexCache (VS.fromList [1]) (VS.fromList [3]))
, testCase "a SimplexCache with a garbage count stays inside its arrays" $
alloca $ \(p :: Ptr SimplexCache) -> do
fillBytes p 0xff (sizeOf (undefined :: SimplexCache))
cache <- peek p
VS.length cache.indexA @?= 3
VS.length cache.indexB @?= 3
, testCase "Counters reads a live world with full-length arrays" $
withWorld (Vec2 0 (-10)) $ \w -> do
_ <- dynamicCircle w (Pos 0 5) 0.5
_ <- dynamicCircle w (Pos 3 5) 0.5
World.step w (1 / 60) 4
counters <- alloca $ \p -> World.getCounters w p >> peek p
counters.bodyCount @?= 2
VS.length counters.colorCounts @?= 24
assertBool "byte count is positive" (counters.byteCount > 0)
]
-- | Build a world with the given gravity and default settings.
withWorld :: Vec2 -> (WorldId -> IO a) -> IO a
withWorld gravity act = do
wd <- defaultWorldDef
w <- World.create wd{gravity}
r <- act w
World.destroy w
pure r
-- | A dynamic body carrying a unit circle at the given position.
dynamicCircle :: WorldId -> Pos -> Float -> IO BodyId
dynamicCircle w position radius = do
bd <- defaultBodyDef
b <- Body.create w bd{type_ = DynamicBody, position = position}
sd <- defaultShapeDef
_ <- Shape.createCircle b sd (Circle vec2Zero radius)
pure b
createOpenChain :: BodyId -> VS.Vector Vec2 -> IO ChainId
createOpenChain body points = do
cd <- Chain.defaultChainDef
VS.unsafeWith points $ \pts ->
Chain.create
body
cd
{ Chain.points = pts
, Chain.pointCount = fromIntegral (VS.length points)
, Chain.ghost1 = extrapolate (VS.head points) (points VS.! 1)
, Chain.ghost2 = extrapolate (VS.last points) (points VS.! (VS.length points - 2))
}
where
extrapolate end inner = vec2Sub (vec2Scale 2 end) inner
posY :: Pos -> Float
posY p = y
where
Vec2 _ y = toVec2 p
simulationTests :: [TestTree]
simulationTests =
[ testCase "world is valid until destroyed" $ do
wd <- defaultWorldDef
w <- World.create wd
World.isValid w >>= (@?= True)
World.destroy w
World.isValid w >>= (@?= False)
, testCase "gravity can be read and set" $
withWorld (Vec2 0 (-10)) $ \w -> do
World.getGravity w >>= (@?= Vec2 0 (-10))
World.setGravity w (Vec2 0 (-20))
World.getGravity w >>= (@?= Vec2 0 (-20))
, testCase "a body and its shape wire up correctly" $
withWorld (Vec2 0 (-10)) $ \w -> do
bd <- defaultBodyDef
b <- Body.create w bd{type_ = DynamicBody, position = Pos 0 10}
Body.isValid b >>= (@?= True)
Body.getType b >>= (@?= DynamicBody)
Body.getPosition b >>= (@?= Pos 0 10)
sd <- defaultShapeDef
s <- Shape.createCircle b sd (Circle vec2Zero 0.5)
Shape.isValid s >>= (@?= True)
Shape.getType s >>= (@?= CircleShape)
Shape.getBody s >>= (@?= b)
, testCase "a dynamic body falls under gravity" $
withWorld (Vec2 0 (-10)) $ \w -> do
b <- dynamicCircle w (Pos 0 10) 0.5
replicateM_ 60 (World.step w (1 / 60) 4)
y <- posY <$> Body.getPosition b
-- one second of free fall from rest under g = -10 lands near y = 5
assertBool ("body fell, y = " <> show y) (y > 4 && y < 6)
Vec2 _ vy <- Body.getLinearVelocity b
assertBool ("velocity is downward, vy = " <> show vy) (vy < -8)
, testCase "a dynamic body rests on a static ground segment" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
ground <- Body.create w gd
gsd <- defaultShapeDef
_ <- Shape.createSegment ground gsd (Segment (Vec2 (-20) 0) (Vec2 20 0))
ball <- dynamicCircle w (Pos 0 5) 0.5
replicateM_ 240 (World.step w (1 / 60) 4)
y <- posY <$> Body.getPosition ball
-- the ball comes to rest on top of the segment: y ~= 0.5
assertBool ("ball rests on the ground, y = " <> show y) (y > 0.3 && y < 0.7)
]
{- | Exercise a spread of functions that only exist thanks to the generator:
Tier A scalar/bool accessors, a coerced enum, and the @b2AABB@-returning
shim (b2World_GetBounds), none of which the hand-written layer bound.
-}
generatedTests :: [TestTree]
generatedTests =
[ testCase "body mass, density and awake flag are readable" $
withWorld (Vec2 0 (-10)) $ \w -> do
b <- dynamicCircle w (Pos 0 10) 0.5
m <- Body.getMass b
assertBool ("mass is positive, m = " <> show m) (m > 0)
Body.getGravityScale b >>= approx 1
Body.setAwake b True
Body.isAwake b >>= (@?= True)
, testCase "shape density defaults to 1" $
withWorld (Vec2 0 (-10)) $ \w -> do
bd <- defaultBodyDef
b <- Body.create w bd{type_ = DynamicBody}
sd <- defaultShapeDef
shp <- Shape.createCircle b sd (Circle vec2Zero 0.5)
Shape.getDensity shp >>= approx 1
Shape.getType shp >>= (@?= CircleShape)
, testCase "world reports awake bodies and finite bounds" $
withWorld (Vec2 0 (-10)) $ \w -> do
_ <- dynamicCircle w (Pos 0 10) 0.5
World.step w (1 / 60) 4
n <- World.getAwakeBodyCount w
assertBool ("at least one awake body, n = " <> show n) (n >= 1)
World.isSleepingEnabled w >>= (@?= True)
bounds <- World.getBounds w
isValidAABB bounds >>= (@?= True)
]
{- | Exercise the Tier B path unlocked by the hand-written joint-def Storables:
build a @DistanceJointDef@ value (setting fields on the embedded base),
create the joint, and confirm it constrains the bodies.
-}
jointTests :: [TestTree]
jointTests =
[ testCase "a rigid distance joint hangs a body below a static anchor" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
anchor <- Body.create w gd{Body.position = Pos 0 10}
bd <- defaultBodyDef
hanging <- Body.create w bd{type_ = DynamicBody, position = Pos 0 8}
sd <- defaultShapeDef
_ <- Shape.createCircle hanging sd (Circle vec2Zero 0.25)
djd <- defaultDistanceJointDef
let
base = djd.base
def =
djd
{ base =
base{bodyIdA = anchor, bodyIdB = hanging}
, length = 2
}
j <- DistanceJoint.create w def
Joint.isValid j >>= (@?= True)
Joint.getType j >>= (@?= DistanceJoint)
Joint.getBodyA j >>= (@?= anchor)
Joint.getBodyB j >>= (@?= hanging)
DistanceJoint.getRestLength j >>= approx 2
replicateM_ 120 (World.step w (1 / 60) 4)
-- rigid length 2 below the anchor at (0, 10): the body settles near y = 8
y <- posY <$> Body.getPosition hanging
assertBool ("body hangs ~2 below anchor, y = " <> show y) (y > 7.5 && y < 8.5)
, testCase "default joint defs round-trip through their Storable" $ do
let rt mk = mk >>= \x -> roundTrip x >>= (@?= x)
rt defaultDistanceJointDef
rt defaultMotorJointDef
rt defaultPrismaticJointDef
rt defaultRevoluteJointDef
rt defaultWeldJointDef
rt defaultWheelJointDef
rt defaultFilterJointDef
]
{- | Exercise the hand-written event-buffer marshalling (roadmap phase 5): the
generated @World.getBodyEvents@ returns the raw buffer, "Box2D.Events" copies
the elements into a list.
-}
eventTests :: [TestTree]
eventTests =
[ testCase "a moved body appears in the body move events" $
withWorld (Vec2 0 (-10)) $ \w -> do
b <- dynamicCircle w (Pos 0 10) 0.5
replicateM_ 4 (World.step w (1 / 60) 4)
evs <- Events.bodyMoveEvents w
assertBool "at least one move event" (not (VS.null evs))
assertBool
"the falling body is among the moved bodies"
(b `VS.elem` VS.map (.bodyId) evs)
, testCase "a body falling through a sensor produces begin and end events" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
sensorBody <- Body.create w gd
gsd <- defaultShapeDef
sensor <-
Shape.createCircle
sensorBody
gsd{isSensor = 1, enableSensorEvents = 1}
(Circle vec2Zero 1)
bd <- defaultBodyDef
visitor <- Body.create w bd{type_ = DynamicBody, position = Pos 0 4}
sd <- defaultShapeDef
_ <-
Shape.createCircle
visitor
sd{enableSensorEvents = 1}
(Circle vec2Zero 0.25)
(begins, ends) <- collectEvents 240 w $ \acc -> do
bs <- Events.sensorBeginTouchEvents w
es <- Events.sensorEndTouchEvents w
let (b0, e0) = acc
pure (b0 <> bs, e0 <> es)
assertBool
"a begin touch event names the sensor"
(sensor `VS.elem` VS.map (.sensorShapeId) begins)
assertBool
"an end touch event names the sensor"
(sensor `VS.elem` VS.map (.sensorShapeId) ends)
, testCase "a landing body produces contact begin, hit, and (once destroyed) end events" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
ground <- Body.create w gd
gsd <- defaultShapeDef
_ <-
Shape.createSegment
ground
gsd{enableContactEvents = 1, enableHitEvents = 1}
(Segment (Vec2 (-10) 0) (Vec2 10 0))
bd <- defaultBodyDef
ball <- Body.create w bd{type_ = DynamicBody, position = Pos 0 3}
sd <- defaultShapeDef
bshape <-
Shape.createCircle
ball
sd{enableContactEvents = 1, enableHitEvents = 1}
(Circle vec2Zero 0.5)
(begins, hits) <- collectEvents 120 w $ \acc -> do
bs <- Events.contactBeginTouchEvents w
hs <- Events.contactHitEvents w
let (b0, h0) = acc
pure (b0 <> bs, h0 <> hs)
let involves a b e = a e == bshape || b e == bshape
assertBool
"a contact begin event involves the ball"
(VS.any (involves (.shapeIdA) (.shapeIdB)) begins)
assertBool
"a hit event involves the ball"
(VS.any (involves (.shapeIdA) (.shapeIdB)) hits)
assertBool
"the hit approach speed is positive"
(VS.all ((> 0) . (.approachSpeed)) hits && not (VS.null hits))
-- destroying the touching body ends the contact on the next step
Body.destroy ball
World.step w (1 / 60) 4
ends <- Events.contactEndTouchEvents w
assertBool
"a contact end event involves the (destroyed) ball shape"
(VS.any (involves (.shapeIdA) (.shapeIdB)) ends)
, testCase "a loaded joint with a low force threshold produces joint events" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
anchor <- Body.create w gd{Body.position = Pos 0 10}
bd <- defaultBodyDef
hanging <- Body.create w bd{type_ = DynamicBody, position = Pos 0 8}
sd <- defaultShapeDef
_ <- Shape.createCircle hanging sd (Circle vec2Zero 0.25)
djd <- defaultDistanceJointDef
let
base = djd.base
def =
djd
{ base =
base{bodyIdA = anchor, bodyIdB = hanging}
, length = 2
}
j <- DistanceJoint.create w def
-- the hanging body loads the joint with ~mg newtons; any tiny
-- threshold guarantees an event
Joint.setForceThreshold j 0.01
(evs, _) <- collectEvents 60 w $ \acc -> do
es <- Events.jointEvents w
let (e0, u) = acc
pure (e0 <> es, u :: ())
assertBool
"a joint event names the loaded joint"
(j `VS.elem` VS.map (.jointId) evs)
]
{- | Step the world @n@ times, folding the per-step event buffers into an
accumulator (events only survive until the next step, so they must be
collected inside the loop).
-}
collectEvents :: (Monoid acc) => Int -> WorldId -> (acc -> IO acc) -> IO acc
collectEvents n w gather = go n mempty
where
go 0 acc = pure acc
go k acc = do
World.step w (1 / 60) 4
acc' <- gather acc
go (k - 1) acc'
{- | Exercise the Polygon/Hull/ChainDef/ExplosionDef value APIs requested by
downstream (apecs-box2d): boxes, arbitrary hulls, chain terrain, explosions
and integer user data.
-}
polygonTests :: [TestTree]
polygonTests =
[ testCase "makeBox round-trips and gives a unit box the right mass" $
withWorld (Vec2 0 0) $ \w -> do
box <- Collision.makeBox 0.5 0.5
VS.length (box.vertices) @?= 4
VS.length (box.normals) @?= 4
roundTrip box >>= (@?= box)
bd <- defaultBodyDef
b <- Body.create w bd{type_ = DynamicBody}
sd <- defaultShapeDef
sh <- Shape.createPolygon b sd box
Shape.getType sh >>= (@?= PolygonShape)
-- a 1x1 box at density 1
md <- Body.getMassData b
approx 1 (md.mass)
, testCase "computeHull and makePolygon build a triangle" $
VS.unsafeWith (VS.fromList [Vec2 0 0, Vec2 1 0, Vec2 0 1]) $ \pts -> do
hull <- Collision.computeHull pts 3
VS.length (hull.points) @?= 3
tri <- Collision.makePolygon hull 0
VS.length (tri.vertices) @?= 3
tri.radius @?= 0
, testCase "a chain assembles from a point array" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
ground <- Body.create w gd
c <- createOpenChain ground (VS.fromList [Vec2 (-6) 2, Vec2 (-4) 0, Vec2 0 0, Vec2 4 0, Vec2 6 2])
Chain.isValid c >>= (@?= True)
n <- Chain.getSegmentCount c
assertBool ("chain has segments, n = " <> show n) (n > 0)
Chain.destroy c
, testCase "an explosion pushes a nearby body away" $
withWorld (Vec2 0 0) $ \w -> do
b <- dynamicCircle w (Pos 1 0) 0.5
ed <- defaultExplosionDef
World.explode
w
ed
{ position = Pos 0 0
, radius = 2
, impulsePerLength = 5
}
Vec2 vx _ <- Body.getLinearVelocity b
assertBool ("pushed away from the blast, vx = " <> show vx) (vx > 0)
, testCase "user indices round-trip through body, shape and joint slots" $
withWorld (Vec2 0 0) $ \w -> do
bd <- defaultBodyDef
b <- Body.create w bd
sd <- defaultShapeDef
sh <- Shape.createCircle b sd (Circle vec2Zero 0.5)
UD.setUserIndex b 42
UD.getUserIndex b >>= (@?= 42)
UD.setUserIndex sh maxBound
UD.getUserIndex sh >>= (@?= maxBound)
-- negative ids survive the pointer pun
UD.setUserIndex b (-7)
UD.getUserIndex b >>= (@?= (-7))
]
-- | A static body carrying a circle at the given position.
staticCircle :: WorldId -> Pos -> Float -> IO ShapeId
staticCircle w position radius = do
bd <- defaultBodyDef
b <- Body.create w bd{Body.position = position}
sd <- defaultShapeDef
Shape.createCircle b sd (Circle vec2Zero radius)
{- | Exercise the query/cast structs upgraded to value APIs: 'RayResult',
'QueryFilter', 'MassData', 'RayCastInput' and 'CastOutput' cross the FFI
by value instead of as phantom pointers.
-}
queryTests :: [TestTree]
queryTests =
[ testCase "the default query filter round-trips and accepts everything" $ do
qf <- defaultQueryFilter
roundTrip qf >>= (@?= qf)
assertBool "mask accepts everything" (qf.maskBits /= 0)
, testCase "castRayClosest reports the nearest shape, and misses cleanly" $
withWorld (Vec2 0 0) $ \w -> do
near <- staticCircle w (Pos 2 0) 0.5
_far <- staticCircle w (Pos 4 0) 0.5
qf <- defaultQueryFilter
r <- World.castRayClosest w (Pos 0 0) (Vec2 10 0) qf
r.hit @?= 1
r.shapeId @?= near
-- the near circle's surface is at x = 1.5, 15% along the ray
approx 0.15 (r.fraction)
miss <- World.castRayClosest w (Pos 0 0) (Vec2 (-10) 0) qf
miss.hit @?= 0
, testCase "body mass data matches the circle's area" $
withWorld (Vec2 0 0) $ \w -> do
b <- dynamicCircle w (Pos 0 0) 0.5
md <- Body.getMassData b
-- density 1 * pi r^2
approx (pi * 0.25) (md.mass)
, testCase "a local-frame ray cast hits a circle" $ do
out <- Collision.castRayCircle (Circle vec2Zero 1) (RayCastInput (Vec2 (-3) 0) (Vec2 6 0) 1)
out.hit @?= 1
-- enters the unit circle at x = -1: a third of the way along (6, 0)
approx (1 / 3) (out.fraction)
]
-- | The C custom-filter callback: @bool (b2ShapeId, b2ShapeId, void*)@.
type CustomFilterCb = ShapeId -> ShapeId -> Ptr () -> IO CBool
foreign import ccall "wrapper"
mkCustomFilterCb :: CustomFilterCb -> IO (FunPtr CustomFilterCb)
{- | Exercise the raw-'FunPtr' callback passthrough: wrap a Haskell closure and
confirm the engine invokes it during the (safe) 'Box2D.World.step'.
-}
callbackTests :: [TestTree]
callbackTests =
[ testCase "a custom-filter callback fires during the step" $
withWorld (Vec2 0 0) $ \w -> do
fired <- newIORef False
cb <- mkCustomFilterCb $ \_ _ _ -> writeIORef fired True >> pure 1
World.setCustomFilterCallback w (castFunPtr cb) nullPtr
let overlappingCircle = do
bd <- defaultBodyDef
b <- Body.create w bd{type_ = DynamicBody}
sd <- defaultShapeDef
_ <- Shape.createCircle b sd{enableCustomFiltering = 1} (Circle vec2Zero 0.5)
pure b
_ <- overlappingCircle
_ <- overlappingCircle
replicateM_ 8 (World.step w (1 / 60) 4)
-- unhook before freeing the wrapper so the world never dangles it
World.setCustomFilterCallback w nullFunPtr nullPtr
freeHaskellFunPtr cb
readIORef fired >>= (@?= True)
, testCase "overlap and cast-ray closures visit shapes (cast via the C trampoline)" $
withWorld (Vec2 0 0) $ \w -> do
_ <- staticCircle w (Pos 2 0) 0.5
_ <- staticCircle w (Pos 4 0) 0.5
_ <- staticCircle w (Pos 40 0) 0.5
qf <- defaultQueryFilter
-- overlap: a plain scalar-args closure, no trampoline needed
seen <- newIORef (0 :: Int)
_ <- CB.withOverlapResultFcn (\_ -> modifyIORef' seen (+ 1) >> pure True) $ \fcn ctx ->
World.overlapAABB w (Pos 0 0) (AABB (Vec2 0 (-1)) (Vec2 5 1)) qf fcn ctx
readIORef seen >>= (@?= 2)
-- cast ray: the by-value point/normal cross through the trampoline
hitsRef <- newIORef []
_ <-
CB.withCastResultFcn
(\sid point normal frac -> modifyIORef' hitsRef ((sid, point, normal, frac) :) >> pure 1)
(\fcn ctx -> World.castRay w (Pos 0 0) (Vec2 10 0) qf fcn ctx)
hits <- readIORef hitsRef
length hits @?= 2
let fracs = map (\(_, _, _, f) -> f) hits
-- surfaces at x = 1.5 and x = 3.5 along a length-10 ray
approx 0.15 (minimum fracs)
approx 0.35 (maximum fracs)
forM_ hits $ \(_, Pos px _, Vec2 nx _, _) -> do
assertBool ("hit point on a surface, x = " <> show px) (px > 1 && px < 4)
approx (-1) nx
, testCase "double mode keeps fractions of a metre far from the origin" $ do
dp <- Base.isDoublePrecision
when dp $ withWorld (Vec2 0 (-10)) $ \w -> do
b <- dynamicCircle w (Pos 10000000.25 10) 0.5
World.step w (1 / 60) 4
Pos x _ <- Body.getPosition b
x @?= 10000000.25
WorldTransform (Pos tx _) _ <- Body.getTransform b
tx @?= 10000000.25
, testCase "a pre-solve manifold edit disables contacts" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
ground <- Body.create w gd
gsd <- defaultShapeDef
_ <-
Shape.createSegment
ground
gsd{enablePreSolveEvents = 1}
(Segment (Vec2 (-10) 0) (Vec2 10 0))
bd <- defaultBodyDef
ball <- Body.create w bd{type_ = DynamicBody, position = Pos 0 2}
sd <- defaultShapeDef
_ <-
Shape.createCircle
ball
sd{enablePreSolveEvents = 1}
(Circle vec2Zero 0.5)
fired <- newIORef (0 :: Int)
let dropPoints _ _ pManifold = do
modifyIORef' fired (+ 1)
m <- peek pManifold
poke pManifold (Manifold m.normal m.rollingImpulse VS.empty)
CB.withPreSolveFcns (Just dropPoints) Nothing $ \solveFcn continuousFcn ctx -> do
World.setPreSolveCallback w solveFcn continuousFcn ctx
replicateM_ 240 (World.step w (1 / 60) 4)
World.setPreSolveCallback w nullFunPtr nullFunPtr nullPtr
readIORef fired >>= \n ->
assertBool ("the gate fired, n = " <> show n) (n > 0)
-- every contact was vetoed, so the ball fell through the ground
y <- posY <$> Body.getPosition ball
assertBool ("ball fell through, y = " <> show y) (y < 0)
, testCase "friction and restitution mixing closures fire on contact" $
withWorld (Vec2 0 (-10)) $ \w -> do
fCount <- newIORef (0 :: Int)
rCount <- newIORef (0 :: Int)
CB.withFrictionCallback (\fa _ fb _ -> modifyIORef' fCount (+ 1) >> pure (max fa fb)) $ \ffcn ->
CB.withRestitutionCallback (\ra _ rb _ -> modifyIORef' rCount (+ 1) >> pure (max ra rb)) $ \rfcn -> do
World.setFrictionCallback w ffcn
World.setRestitutionCallback w rfcn
gd <- defaultBodyDef
ground <- Body.create w gd
gsd <- defaultShapeDef
_ <- Shape.createSegment ground gsd (Segment (Vec2 (-10) 0) (Vec2 10 0))
_ <- dynamicCircle w (Pos 0 1) 0.5
replicateM_ 60 (World.step w (1 / 60) 4)
-- NULL resets the world to the default mixers before the wrappers die
World.setFrictionCallback w nullFunPtr
World.setRestitutionCallback w nullFunPtr
readIORef fCount >>= \n ->
assertBool ("friction mixer fired, n = " <> show n) (n > 0)
readIORef rCount >>= \n ->
assertBool ("restitution mixer fired, n = " <> show n) (n > 0)
, testCase "a mover capsule gathers collision planes through the closure" $
withWorld (Vec2 0 0) $ \w -> do
_ <- staticCircle w (Pos 0 0) 1
qf <- defaultQueryFilter
planes <- newIORef []
CB.withPlaneResultFcn
(\sid pr -> modifyIORef' planes ((sid, pr) :) >> pure True)
(\fcn ctx -> World.collideMover w (Pos 0 0) (Capsule (Vec2 1.2 0) (Vec2 1.2 0.5) 0.4) qf fcn ctx)
ps <- readIORef planes
assertBool "at least one plane gathered" (not (null ps))
assertBool "a plane registered a hit" (any ((== 1) . (.hit) . snd) ps)
, testCase "dynamic-tree query and ray-cast closures visit proxies" $
bracket (DynamicTree.create 16) DynamicTree.destroy $ \tree -> do
p0 <- DynamicTree.createProxy tree (AABB (Vec2 0 0) (Vec2 1 1)) 1 42
_ <- DynamicTree.createProxy tree (AABB (Vec2 3 0) (Vec2 4 1)) 1 43
-- query: only the first box intersects the probe AABB
seen <- newIORef []
_ <- CB.withTreeQueryFcn (\pid ud -> modifyIORef' seen ((pid, ud) :) >> pure True) $ \fcn ctx ->
DynamicTree.overlapAABB tree (AABB (Vec2 (-0.5) 0.25) (Vec2 0.5 0.75)) maxBound fcn ctx
readIORef seen >>= (@?= [(fromIntegral p0, 42)])
-- a ray along y = 0.5 pierces both boxes
hits <- newIORef (0 :: Int)
_ <-
CB.withTreeRayCastFcn
( \input _pid _ud -> do
input.maxFraction @?= 1
modifyIORef' hits (+ 1)
pure 1
)
(\fcn ctx -> DynamicTree.castRay tree (RayCastInput (Vec2 (-1) 0.5) (Vec2 6 0) 1) maxBound fcn ctx)
readIORef hits >>= (@?= 2)
, testCase "custom-filter closures survive concurrent hot-path invocation" $
CB.withThreadPoolTaskSystem 4 $ \ts -> do
total <- newIORef (0 :: Int)
inFlight <- newIORef (0 :: Int)
maxSeen <- newIORef (0 :: Int)
canOverlap <- (>= 2) <$> getNumCapabilities
deadline <- (+ 5) <$> getMonotonicTime
-- track how many callback invocations overlap, and widen the window a
-- little so genuine parallelism actually shows up
let onFilter _ _ _ = do
n <- atomicModifyIORef' inFlight (\x -> (x + 1, x + 1))
atomicModifyIORef' maxSeen (\m -> (max m n, ()))
when canOverlap (awaitOverlap deadline maxSeen)
spin 4000
atomicModifyIORef' total (\x -> (x + 1, ()))
atomicModifyIORef' inFlight (\x -> (x - 1, ()))
pure True
CB.withCustomFilterFcn onFilter $ \fcn -> do
wd <- defaultWorldDef
w <-
World.create
wd
{ gravity = Vec2 0 (-10)
, enableSleep = 0
, workerCount = fromIntegral ts.workerCount
, enqueueTask = ts.enqueue
, finishTask = ts.finish
}
World.setCustomFilterCallback w fcn nullPtr
-- a static ground plus a 16x16 grid of custom-filtered circles: lots
-- of broad-phase pairs churning through the parallel solver
gd <- defaultBodyDef
ground <- Body.create w gd
gsd <- defaultShapeDef
_ <- Shape.createSegment ground gsd (Segment (Vec2 (-40) 0) (Vec2 40 0))
bd <- defaultBodyDef
sd <- defaultShapeDef
forM_ [0 .. 255 :: Int] $ \i -> do
let
col = fromIntegral (i `mod` 16)
row = fromIntegral (i `div` 16)
pos = Pos (col - 8) (1.5 + row * 1.1)
b <- Body.create w bd{type_ = DynamicBody, position = pos}
_ <- Shape.createCircle b sd{enableCustomFiltering = 1} (Circle vec2Zero 0.5)
pure ()
replicateM_ 300 (World.step w (1 / 60) 4)
World.setCustomFilterCallback w nullFunPtr nullPtr
World.isValid w >>= (@?= True)
World.destroy w
calls <- readIORef total
peak <- readIORef maxSeen
assertBool ("callback fired under load, calls = " <> show calls) (calls > 100)
when canOverlap $
assertBool
("callback was invoked concurrently, peak overlap = " <> show peak)
(peak >= 2)
, testCase "the debug-draw table dispatches to Haskell closures" $
withWorld (Vec2 0 0) $ \w -> do
_ <- dynamicCircle w (Pos 1 2) 0.5
bd <- defaultBodyDef
boxBody <- Body.create w bd{type_ = DynamicBody, position = Pos 5 2}
sd <- defaultShapeDef
boxShape <- Collision.makeBox 0.5 0.5
_ <- Shape.createPolygon boxBody sd boxShape
World.step w (1 / 60) 4
circles <- newIORef []
polys <- newIORef []
boxes <- newIORef (0 :: Int)
base <- defaultDebugDraw
let draw =
CB.noDraw
{ CB.solidCircle = Just $ \(WorldTransform p _) _ r c -> modifyIORef' circles ((p, r, c) :)
, CB.solidPolygon = Just $ \_ vs _ c -> modifyIORef' polys ((VS.length vs, c) :)
, CB.bounds = Just $ \_ _ -> modifyIORef' boxes (+ 1)
}
CB.withDraw draw base{drawBounds = 1} (CB.draw w)
readIORef circles >>= \cs -> case cs of
[(p, r, _)] -> do
let Vec2 px py = toVec2 p
r @?= 0.5
approx 1 px
approx 2 py
_ -> assertFailure ("expected one circle, got " <> show (length cs))
readIORef polys >>= \ps -> case ps of
[(n, _)] -> n @?= 4
_ -> assertFailure ("expected one polygon, got " <> show (length ps))
readIORef boxes >>= \n ->
assertBool ("shape bounds were drawn, n = " <> show n) (n >= 2)
, testCase "an exception in a draw callback surfaces at draw, not at the callback" $
withWorld (Vec2 0 0) $ \w -> do
_ <- dynamicCircle w (Pos 1 2) 0.5
World.step w (1 / 60) 4
later <- newIORef (0 :: Int)
-- a per-frame message, so a stale trap is not mistaken for a fresh one
frame <- newIORef (1 :: Int)
base <- defaultDebugDraw
let draw =
CB.noDraw
{ CB.solidCircle = Just $ \_ _ _ _ -> readIORef frame >>= \i -> error ("boom" <> show i)
, -- the engine keeps walking, but every later slot is skipped
CB.bounds = Just $ \_ _ -> modifyIORef' later (+ 1)
}
CB.withDraw draw base{drawBounds = 1} $ \target -> do
CB.draw w target `catchErrorCall` \msg -> msg @?= "boom1"
readIORef later >>= (@?= 0)
-- the trap is cleared, so the next frame starts clean
writeIORef frame 2
CB.draw w target `catchErrorCall` \msg -> msg @?= "boom2"
World.isValid w >>= (@?= True)
, testCase "an unset slot never enters Haskell, so its exception cannot fire" $
withWorld (Vec2 0 0) $ \w -> do
_ <- dynamicCircle w (Pos 1 2) 0.5
World.step w (1 / 60) 4
seen <- newIORef (0 :: Int)
base <- defaultDebugDraw
-- bounds is left unset: the trampoline sees NULL and returns
let draw = CB.noDraw{CB.solidCircle = Just $ \_ _ _ _ -> modifyIORef' seen (+ 1)}
CB.withDraw draw base{drawBounds = 1} (CB.draw w)
readIORef seen >>= (@?= 1)
, testCase "withRecorder collects a frame for rendering after draw returns" $
withWorld (Vec2 0 0) $ \w -> do
b <- dynamicCircle w (Pos 1 2) 0.5
World.step w (1 / 60) 4
base <- defaultDebugDraw
((), cmds) <- CB.withRecorder base{drawBounds = 1} (CB.draw w)
let circles = [(xf, r) | CB.DrawSolidCircle xf _ r _ <- cmds]
case circles of
[(WorldTransform p _, r)] -> do
r @?= 0.5
let Vec2 px py = toVec2 p
approx 1 px
approx 2 py
_ -> assertFailure ("expected one circle, got " <> show (length circles))
assertBool "bounds were recorded too" (any isBounds cmds)
-- the world is safe to mutate now that draw has returned
Body.destroy b
World.isValid w >>= (@?= True)
, testCase "toWorld moves local geometry into the transform's frame" $ do
let
xf = WorldTransform (Pos 10 20) (Rot 0 1) -- a quarter turn
Vec2 x y = toVec2 (CB.toWorld xf (Vec2 1 0))
approx 10 x
approx 21 y
]
isBounds :: CB.Cmd -> Bool
isBounds (CB.DrawBounds _ _) = True
isBounds _ = False
catchErrorCall :: IO () -> (String -> IO ()) -> IO ()
catchErrorCall act onError = do
thrown <- try act
case thrown of
Left (ErrorCall msg) -> onError msg
Right () -> assertFailure "expected an ErrorCall"
-- | A tiny busy-wait that GHC will not optimise away, to widen a race window.
spin :: Int -> IO ()
spin = go
where
go 0 = pure ()
go k = evaluate k >> go (k - 1)
awaitOverlap :: Double -> IORef Int -> IO ()
awaitOverlap deadline maxSeen = do
peak <- readIORef maxSeen
now <- getMonotonicTime
unless (peak >= 2 || now >= deadline) (yield >> awaitOverlap deadline maxSeen)
moverTests :: [TestTree]
moverTests =
[ testCase "makeProxy fills a peekable ShapeProxy and withShapeProxy pokes one back" $ do
let points = VS.fromList [Vec2 0 0, Vec2 1 0, Vec2 0 1]
proxy <- VS.unsafeWith points $ \pts -> Collision.makeProxy pts (VS.length points) 0.25
proxy.points @?= points
proxy.radius @?= 0.25
roundTrip proxy >>= (@?= proxy)
withShapeProxy points 0.25 $ \p -> peek p >>= (@?= proxy)
, testCase "a begun contact exposes its manifold through Contact.getData" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
ground <- Body.create w gd
gsd <- defaultShapeDef
_ <- Shape.createCircle ground gsd{enableContactEvents = 1} (Circle vec2Zero 5)
bd <- defaultBodyDef
ball <- Body.create w bd{type_ = DynamicBody, position = Pos 0 5.6}
sd <- defaultShapeDef
bshape <- Shape.createCircle ball sd{enableContactEvents = 1} (Circle vec2Zero 0.5)
let loop 0 = assertFailure "no contact begin event within 120 steps"
loop n = do
World.step w (1 / 60) 4
evs <- Events.contactBeginTouchEvents w
if VS.null evs then loop (n - 1 :: Int) else pure (VS.head evs)
ev <- loop 120
let cid = ev.contactId
Contact.isValid cid >>= (@?= True)
cd <- Contact.getData cid
assertBool
"the contact involves the ball shape"
(cd.shapeIdA == bshape || cd.shapeIdB == bshape)
let m = cd.manifold
assertBool "the manifold has a contact point" (not (VS.null (m.points)))
let Vec2 _ ny = m.normal
assertBool ("the contact normal is vertical, " <> show (m.normal)) (abs ny > 0.9)
, testCase "solvePlanes pushes a mover out and clipVector kills the inward velocity" $ do
let plane = CollisionPlane (Plane (Vec2 0 1) 0) (1 / 0) 0 1
(delta, planes', iters) <- Mover.solvePlanes (Vec2 0 (-1)) (VS.singleton plane)
let Vec2 _ dy = delta
assertBool ("the mover ends up at the plane, delta = " <> show delta) (abs dy < 0.01)
assertBool ("the solver reports iterations, " <> show iters) (iters >= 1)
assertBool "the plane accumulated push" ((VS.head planes').push > 0)
clipped <- Mover.clipVector (Vec2 3 (-5)) planes'
clipped @?= Vec2 3 0
, testCase "chain contact and hit events toggle across all segments" $
withWorld (Vec2 0 (-10)) $ \w -> do
gd <- defaultBodyDef
ground <- Body.create w gd
c <- createOpenChain ground (VS.fromList [Vec2 (-6) 2, Vec2 (-4) 0, Vec2 4 0, Vec2 6 2])
n <- Chain.getSegmentCount c
segments <- allocaArray n $ \buf -> do
got <- Chain.getSegments c buf n
peekArray got buf
assertBool "the chain has segments" (not (null segments))
mapM_ (\s -> Shape.areContactEventsEnabled s >>= (@?= False)) segments
Chain.enableContactEvents c True
Chain.enableHitEvents c True
mapM_ (\s -> Shape.areContactEventsEnabled s >>= (@?= True)) segments
mapM_ (\s -> Shape.areHitEventsEnabled s >>= (@?= True)) segments
, testCase "the default length scale is one unit per meter" $
getLengthUnitsPerMeter >>= (@?= 1)
, testCase "the pure vector helpers follow the engine conventions" $ do
vec2Add (Vec2 1 2) (Vec2 3 4) @?= Vec2 4 6
vec2MulSub (Vec2 1 1) 2 (Vec2 0 1) @?= Vec2 1 (-1)
vec2Cross (Vec2 1 0) (Vec2 0 1) @?= 1
vec2Normalize (Vec2 3 4) @?= Vec2 0.6 0.8
vec2Normalize vec2Zero @?= vec2Zero
rotateVector rotIdentity (Vec2 5 6) @?= Vec2 5 6
invRotateVector (rotMul rotIdentity (Rot 0 1)) (rotateVector (Rot 0 1) (Vec2 5 6)) @?= Vec2 5 6
planeSeparation (Plane (Vec2 0 1) 2) (Vec2 0 5) @?= 3
]