pure-cdt-0.2.0.0: test/Test/Geometry.hs
{-# OPTIONS_GHC -Wno-orphans #-}
module Test.Geometry (tests, Points (..)) where
import Data.Hashable (hash)
import Data.List (permutations)
import Data.List.NonEmpty (NonEmpty (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
import Test.Tasty.QuickCheck (
Arbitrary (..),
Gen,
choose,
chooseInt,
shrinkList,
testProperty,
vectorOf,
(===),
)
import Triangulation.Geometry
-- | Points inside the 2000×2000 canvas used by the examples.
instance Arbitrary Point where
arbitrary = Point <$> choose (0, 2000) <*> choose (0, 2000)
-- | A point set of at least three points; shrinking never goes below three.
newtype Points = Points [Point]
deriving (Show)
instance Arbitrary Points where
arbitrary = do
n <- chooseInt (3, 60)
Points <$> vectorOf n (arbitrary :: Gen Point)
shrink (Points ps) = [Points ps' | ps' <- shrinkList (const []) ps, length ps' >= 3]
tests :: TestTree
tests =
testGroup
"Geometry"
[ orientationTests
, exactnessTests
, edgeTests
, triangleTests
, polygonTests
]
-- | Triples that are collinear or nearly so, where naive floating-point
-- evaluation gets the sign wrong: @c@ is on the line through @a@ and @b@ up
-- to a perturbation of a few ulps.
newtype NearlyCollinear = NearlyCollinear (Point, Point, Point)
deriving (Show)
instance Arbitrary NearlyCollinear where
arbitrary = do
a <- arbitrary
b <- arbitrary
t <- choose (-1, 2 :: Double)
ulps <- chooseInt (-3, 3)
let Point cx cy = Point (px a + t * (px b - px a)) (py a + t * (py b - py a))
pure $ NearlyCollinear (a, b, Point (nudge ulps cx) cy)
where
nudge n x = x + fromIntegral n * (x * 2.220446049250313e-16)
-- | Points on a circle (up to rounding) around a centre, plus one more nearly on it.
newtype NearlyCocircular = NearlyCocircular (Point, Point, Point, Point)
deriving (Show)
instance Arbitrary NearlyCocircular where
arbitrary = do
centre <- arbitrary
radius <- choose (1, 1000)
t1 <- choose (0, 2 * pi)
t2 <- choose (0, 2 * pi)
t3 <- choose (0, 2 * pi)
t4 <- choose (0, 2 * pi)
ulps <- chooseInt (-3, 3)
let onCircle t = Point (px centre + radius * cos t) (py centre + radius * sin t)
Point dx dy = onCircle t4
pure $
NearlyCocircular
( onCircle t1
, onCircle t2
, onCircle t3
, Point (dx + fromIntegral ulps * dx * 2.220446049250313e-16) dy
)
exactnessTests :: TestTree
exactnessTests =
testGroup
"exact predicates"
[ testProperty "orientation agrees with rational arithmetic on random triples" $
\a b c -> orientation a b c === referenceOrientation a b c
, testProperty "orientation agrees with rational arithmetic on nearly collinear triples" $
\(NearlyCollinear (a, b, c)) -> orientation a b c === referenceOrientation a b c
, testProperty "in-circle agrees with rational arithmetic on nearly cocircular points" $
\(NearlyCocircular (a, b, c, d)) ->
inCircleSign (coordinates a) (coordinates b) (coordinates c) (coordinates d)
=== referenceInCircle a b c d
]
where
r = toRational
referenceOrientation (Point ax ay) (Point bx by) (Point cx cy) =
case compare ((r ax - r cx) * (r by - r cy) - (r ay - r cy) * (r bx - r cx)) 0 of
LT -> Clockwise
GT -> CounterClockwise
EQ -> Collinear
referenceInCircle (Point ax ay) (Point bx by) (Point cx cy) (Point dx dy) =
let (adx, ady, bdx, bdy, cdx, cdy) = (r ax - r dx, r ay - r dy, r bx - r dx, r by - r dy, r cx - r dx, r cy - r dy)
in compare
( (adx * adx + ady * ady) * (bdx * cdy - cdx * bdy)
+ (bdx * bdx + bdy * bdy) * (cdx * ady - adx * cdy)
+ (cdx * cdx + cdy * cdy) * (adx * bdy - bdx * ady)
)
0
orientationTests :: TestTree
orientationTests =
testGroup
"orientation"
[ testCase "left turn is counter-clockwise (y axis up)" $
orientation (Point 0 0) (Point 1 0) (Point 1 1) @?= CounterClockwise
, testCase "right turn is clockwise" $
orientation (Point 0 0) (Point 1 0) (Point 1 (-1)) @?= Clockwise
, testCase "points on a line are collinear" $
orientation (Point 0 0) (Point 1 0) (Point 2 0) @?= Collinear
, testCase "turn: collinear with the third point closer counts as clockwise" $
turn (Point 0 0) (Point 2 0) (Point 1 0) @?= Clockwise
, testCase "turn: collinear with the third point farther counts as counter-clockwise" $
turn (Point 0 0) (Point 1 0) (Point 2 0) @?= CounterClockwise
, testProperty "turn never reports collinear" $
\a b c -> turn a b c /= Collinear
, testProperty "reversing the walk flips the orientation" $
\a b c -> orientation a b c === flipOrientation (orientation c b a)
]
where
flipOrientation Clockwise = CounterClockwise
flipOrientation CounterClockwise = Clockwise
flipOrientation Collinear = Collinear
edgeTests :: TestTree
edgeTests =
testGroup
"Edge"
[ testProperty "mkEdge is symmetric" $
\a b -> mkEdge a b === mkEdge b a
, testProperty "equal edges hash equally" $
\a b -> hash (mkEdge a b) === hash (mkEdge b a)
, testCase "crossing segments intersect at the crossing" $
intersection (mkEdge (Point 0 0) (Point 2 2)) (mkEdge (Point 0 2) (Point 2 0)) @?= Just (Point 1 1)
, testCase "parallel segments do not intersect" $
intersection (mkEdge (Point 0 0) (Point 1 0)) (mkEdge (Point 0 1) (Point 1 1)) @?= Nothing
, testCase "segments sharing an endpoint do not intersect" $
intersection (mkEdge (Point 0 0) (Point 1 1)) (mkEdge (Point 0 0) (Point 1 0)) @?= Nothing
, testCase "segments on crossing lines but apart do not intersect" $
intersection (mkEdge (Point 0 0) (Point 1 1)) (mkEdge (Point 2 0) (Point 3 1)) @?= Nothing
, testCase "a segment touching the other at an endpoint does not intersect" $
intersection (mkEdge (Point 0 0) (Point 2 0)) (mkEdge (Point 1 0) (Point 1 1)) @?= Nothing
]
triangleTests :: TestTree
triangleTests =
testGroup
"Triangle"
[ testProperty "mkTriangle ignores the order of the vertices" $
\a b c -> all (== mkTriangle a b c) [mkTriangle x y z | [x, y, z] <- permutations [a, b, c]]
, testCase "a point inside the circumcircle" $
isOutsideCircumcircle (Point 1 1) rightTriangle @?= False
, testCase "a point far away is outside the circumcircle" $
isOutsideCircumcircle (Point 5 5) rightTriangle @?= True
, testCase "a point on the circumcircle counts as outside" $
isOutsideCircumcircle (Point 2 2) rightTriangle @?= True
, testCase "isPointInTriangle: inside" $
isPointInTriangle (mkTriangle (Point 0 0) (Point 4 0) (Point 0 4)) (Point 1 1) @?= True
, testCase "isPointInTriangle: outside" $
isPointInTriangle (mkTriangle (Point 0 0) (Point 4 0) (Point 0 4)) (Point 3 3) @?= False
, testCase "isPointInTriangle: a vertex is not inside" $
isPointInTriangle (mkTriangle (Point 0 0) (Point 4 0) (Point 0 4)) (Point 4 0) @?= False
, testCase "triangles are sorted into and out of a polygon by their centroid" $
let ts =
[ mkTriangle (Point 1 1) (Point 2 1) (Point 1 2)
, mkTriangle (Point 10 10) (Point 11 10) (Point 10 11)
]
in (trianglesInside square ts, trianglesOutside square ts) @?= splitAt 1 ts
]
where
rightTriangle = mkTriangle (Point 0 0) (Point 2 0) (Point 0 2)
-- | The unit square scaled to 4, wound clockwise.
square :: Polygon
square = Polygon (Point 0 0 :| [Point 0 4, Point 4 4, Point 4 0])
polygonTests :: TestTree
polygonTests =
testGroup
"Polygon"
[ testCase "hullOf3 starts at the lower-left point and winds clockwise" $
vertices (hullOf3 (Point 2 0) (Point 1 1) (Point 0 0)) @?= [Point 0 0, Point 1 1, Point 2 0]
, testProperty "hullOf3 is convex and clockwise" $
\a b c -> isConvex (hullOf3 a b c)
, testCase "hullOf4 of a square keeps all four corners" $
case hullOf4 (Point 2 2) (Point 0 0) (Point 2 0) (Point 0 2) of
Quadrilateral p1 p2 p3 p4 -> [p1, p2, p3, p4] @?= [Point 0 0, Point 0 2, Point 2 2, Point 2 0]
TriangleWithInner {} -> assertBool "expected a quadrilateral" False
, testCase "hullOf4 finds the inner point" $
case hullOf4 (Point 0 0) (Point 2 1) (Point 4 0) (Point 2 4) of
TriangleWithInner _ _ _ inner -> inner @?= Point 2 1
Quadrilateral {} -> assertBool "expected a triangle with an inner point" False
, testProperty "hullOf4 is convex and clockwise" $
\a b c d -> isConvex (hull4Polygon (hullOf4 a b c d))
, testCase "isPointInPolygon: inside" $ isPointInPolygon square (Point 1 1) @?= True
, testCase "isPointInPolygon: outside" $ isPointInPolygon square (Point 5 5) @?= False
, testCase "isPointInPolygon: a vertex is not inside" $ isPointInPolygon square (Point 4 4) @?= False
, testCase "a square is convex" $ isConvex square @?= True
, testCase "a polygon with a reflex vertex is not convex" $
isConvex (Polygon (Point 0 0 :| [Point 0 3, Point 3 3, Point 1 2])) @?= False
, testCase "polygonEdges closes the ring" $
length (polygonEdges square) @?= 4
]