moonlight-planar-1.2.0.0: test/illustration/Moonlight/Planar/IllustrationStudySpec.hs
module Moonlight.Planar.IllustrationStudySpec (tests) where
import Control.Monad (unless)
import Data.Foldable (toList, traverse_)
import Data.List (nub)
import qualified Data.Sequence as Seq
import Moonlight.Planar.Affine (Affine2, affine2, affineIsoMap, composeAffine2, identityAffine2, transformPoint)
import Moonlight.Planar.Curve
( ClosedTrail, Located, Subpath (..), closedTrailSteps, locate, location, locatedValue, openTrail
, transformLocatedClosedTrail )
import Moonlight.Planar.Curve.Frame (exactTrailSite)
import Moonlight.Planar.Curve.Lowering (loweringPolicy)
import Moonlight.Planar.Curve.Measure (distance, measurePolicy, radicalPrecision, sitePoint)
import Moonlight.Planar.Curve.Proximity (ClearanceVerdict (..), clearance)
import Moonlight.Planar.Curve.Region (CurveComponent (..), certifiedPointLocation, lowerSimpleRegion, subdivisionBudget)
import Moonlight.Planar.Exhibit.IllustrationStudy
import Moonlight.Planar.Exact
( ExactPoint, ExactRational, ExactVector (..), exactHalf, exactPoint, exactPointCoordinates, positiveExact
, positiveOne, unitOne, unitZero )
import Moonlight.Planar.Region (RegionPointLocation (..), planarRegionComponents, polygonHoleLoops)
import Moonlight.Planar.Illustration (PictureAlgebra (..), Paint (..), foldPicture)
-- These are artistic dependency tests, not another curve-equivalence oracle.
tests :: IO ()
tests = do
let parts = [minBound..maxBound] :: [StudyPart]
check "part names uniquely encode the author's vocabulary"
(length (nub (partName <$> parts)) == length parts)
check "horn pair shares one profile through exact reflection"
(partContour defaultControls RightHorn == transformLocatedClosedTrail
(affine2 (ExactVector (-1) 0) (ExactVector 0 1) (ExactVector 820 0))
(partContour defaultControls LeftHorn))
check "left horn port is profile root, not contour seam"
(portPoint defaultControls LeftHorn == exactPoint 336 359
&& portPoint defaultControls LeftHorn /= location (partContour defaultControls LeftHorn))
check "right horn root follows actual reflection"
(portPoint defaultControls RightHorn == exactPoint 484 359)
check "eye port is ellipse center, not seam"
(portPoint defaultControls LeftEye == exactPoint 353 420
&& portPoint defaultControls LeftEye /= location (partContour defaultControls LeftEye))
traverse_ (\part -> check ("actual attachment paints canonical contour: " <> show part)
(any (\(name,contour,_) -> name == Just part && contour == partContour defaultControls part)
(paintedContours defaultControls))) [LeftHorn,RightHorn,LeftEye,RightEye]
check "attached horns retain world porcelain gradient coordinates"
(all (\(_,_,paint) -> case paint of
LinearGradient start end _ -> start == exactPoint 300 330 && end == exactPoint 520 500
_ -> False)
(filter (\(name,_,_) -> name == Just LeftHorn || name == Just RightHorn)
(paintedContours defaultControls)))
traverse_ checkEdit
[(LeftHornSweep,30,[LeftHorn]),(EyeSpacing,66,[LeftEye,RightEye])
,(EyeTilt,12,[LeftEye,RightEye]),(CloakFullness,30,[Cloak]),(FullerWidth,20,[Fuller])]
traverse_ checkRejected
[(LeftHornSweep,46),(EyeSpacing,39),(EyeTilt,16),(CloakFullness,56),(FullerWidth,0)]
checkBladeHole defaultControls
traverse_
(\width -> either (ioError . userError . show) checkBladeHole (editControl FullerWidth (fromInteger width) defaultControls))
fullerWidthBounds
checkHornBases defaultControls
traverse_
(\sweep -> either (ioError . userError . show) checkHornBases (editControl LeftHornSweep (fromInteger sweep) defaultControls))
hornSweepBounds
putStrLn ("illustration study: semantic edits and stable anchors ok; fuller certified strictly inside the blade at the default and FullerWidth "
<> show fullerWidthBounds <> "; horn bases certified inside the mask at the default and LeftHornSweep " <> show hornSweepBounds)
checkEdit :: (StudyControl, ExactRational, [StudyPart]) -> IO ()
checkEdit (control,value,affected) = case editControl control value defaultControls of
Left refusal -> ioError (userError (show refusal))
Right edited -> do
traverse_ (checkPart edited) [minBound..maxBound]
check (show control <> " changes the actual picture")
(studyPicture defaultControls /= studyPicture edited)
check (show control <> " eye centers follow only spacing")
((partPort defaultControls LeftEye /= partPort edited LeftEye) == (control == EyeSpacing))
check (show control <> " horn attachment is fixed")
(partPort defaultControls LeftHorn == partPort edited LeftHorn)
where
checkPart :: StudyControls -> StudyPart -> IO ()
checkPart edited part = do
let original = partContour defaultControls part
candidate = partContour edited part
check (show control <> " geometry dependency: " <> show part)
((original /= candidate) == (part `elem` affected))
-- Sweep/fullness/width edits preserve the authored attachment location;
-- eye edits intentionally move the ellipse seam (spacing moves its center).
unless (control `elem` [EyeSpacing,EyeTilt] && part `elem` affected) $
check (show control <> " retains local anchor: " <> show part)
(location original == location candidate)
checkRejected :: (StudyControl,ExactRational) -> IO ()
checkRejected (control,value) = check (show control <> " rejects out-of-family values") $
case editControl control value defaultControls of
Left (ControlOutsideRange rejected rejectedValue) -> rejected == control && rejectedValue == value
Right _ -> False
check :: String -> Bool -> IO ()
check label result = unless result (ioError (userError label))
portPoint :: StudyControls -> StudyPart -> ExactPoint
portPoint controls part = transformPoint (affineIsoMap (partPort controls part)) (exactPoint 0 0)
-- Observe world contours and translated paint anchors, not nested Picture Eq.
paintedContours :: StudyControls -> [(Maybe StudyPart, Located ClosedTrail, Paint)]
paintedContours controls = foldPicture algebra (studyPicture controls) identityAffine2 Nothing
where
algebra :: PictureAlgebra StudyPart (Affine2 -> Maybe StudyPart -> [(Maybe StudyPart, Located ClosedTrail, Paint)])
algebra = PictureAlgebra
{ paintSequence = \children frame name -> concatMap (\child -> child frame name) (toList children)
, paintFill = \_ paint contours frame name ->
fmap (\contour -> (name,transformLocatedClosedTrail frame contour,worldPaint frame paint)) (toList contours)
, paintStroke = \_ _ _ _ -> []
, paintClip = \_ _ child -> child
, paintPlace = \local child frame -> child (composeAffine2 frame local)
, paintOpacity = \_ child -> child
, paintAnnotation = \part child frame _ -> child frame (Just part)
}
worldPaint :: Affine2 -> Paint -> Paint
worldPaint frame paint = case paint of
LinearGradient a b stops -> LinearGradient (transformPoint frame a) (transformPoint frame b) stops
other -> other
-- The painting fills the blade with the fuller under EvenOdd, so the fuller
-- must lie strictly inside it: the certificate proves both curves simple,
-- disjoint and wound as outer and hole, and the fuller nested inside the
-- blade, and refuses a lowered polygon that nests otherwise. The tested scope
-- is the default controls and the fuller's editable bounds.
fullerWidthBounds :: [Integer]
fullerWidthBounds = [5, 24]
checkBladeHole :: StudyControls -> IO ()
checkBladeHole controls =
case (loweringPolicy positiveOne identityAffine2 20 8192, subdivisionBudget 12 4096 4096) of
(Left refusal, _) -> ioError (userError (show refusal))
(_, Left refusal) -> ioError (userError (show refusal))
(Right policy, Right budget) -> case lowerSimpleRegion policy budget
[CurveComponent (partContour controls Blade) [partContour controls Fuller]] of
Left refusal -> ioError (userError (show refusal))
Right (region,_,_) -> check "pierced blade retains exactly one admitted hole"
(map (length . polygonHoleLoops) (planarRegionComponents region) == [1])
-- The horns are painted beneath the mask and must attach under it: each
-- horn's base, the closing step of its contour across its root, lies inside
-- the mask. A connected segment disjoint from the mask curve with an
-- endpoint inside is wholly inside, since leaving would cross the curve. The
-- base's endpoints are exact sites of the closing step, certified inside by
-- the mask's curve evidence, and strict clearance at zero between the step
-- and the mask curve certifies them disjoint. The tested scope is the
-- default controls and the left horn's editable sweep bounds; the mask is
-- fixed and the sweep moves only the horn's tip.
hornSweepBounds :: [Integer]
hornSweepBounds = [-45, 45]
checkHornBases :: StudyControls -> IO ()
checkHornBases controls = do
lowering <- orFail (loweringPolicy positiveOne identityAffine2 20 8192)
budget <- orFail (subdivisionBudget 12 4096 4096)
tolerance <- orFail (positiveExact 1)
precision <- orFail (radicalPrecision 64)
zero <- orFail (distance 0)
let mask = partContour controls Mask
measuring = measurePolicy tolerance precision budget
(_, _, evidence) <- orFail (lowerSimpleRegion lowering budget [CurveComponent mask []])
traverse_
(\part -> do
let contour = partContour controls part
source = ClosedSubpath contour
steps = closedTrailSteps (locatedValue contour)
closing = Seq.length steps - 1
start <- orFail (exactTrailSite source closing unitZero)
end <- orFail (exactTrailSite source closing unitOne)
check (show part <> " base spans its root")
(midpoint (sitePoint start) (sitePoint end) == portPoint controls part)
check (show part <> " base endpoints certified inside the mask")
(map (certifiedPointLocation evidence) [sitePoint start, sitePoint end] == [Just RegionInterior, Just RegionInterior])
base <- maybe (ioError (userError (show part <> " has no closing step"))) pure (Seq.lookup closing steps)
verdict <- orFail (clearance measuring zero (OpenSubpath (locate (sitePoint start) (openTrail (Seq.singleton base)))) (ClosedSubpath mask))
case verdict of
ClearanceHolds _ -> pure ()
other -> ioError (userError (show part <> " base meets the mask curve: " <> show other)))
[LeftHorn, RightHorn]
where
orFail :: Show refusal => Either refusal value -> IO value
orFail = either (ioError . userError . show) pure
midpoint :: ExactPoint -> ExactPoint -> ExactPoint
midpoint a b =
let (ax, ay) = exactPointCoordinates a
(bx, by) = exactPointCoordinates b
in exactPoint (exactHalf * (ax + bx)) (exactHalf * (ay + by))