packages feed

moonlight-planar-1.2.0.0: test/illustration/Moonlight/Planar/EquationalGardenSpec.hs

module Moonlight.Planar.EquationalGardenSpec (tests) where

import Control.Monad (unless)
import Data.Bifunctor (first)
import Data.Foldable (toList, traverse_)
import Data.List (isInfixOf)
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Sequence as Seq
import Moonlight.Planar.Affine
  ( AffineIso2, affine2, affineColumns, affineIso2, affineIsoMap, composeAffineIso2
  , inverseAffineIso2, transformPoint )
import Moonlight.Planar.Curve
  ( Located, OpenTrail, Path, Subpath (..), curveStepEnd, locatedValue, location, path
  , startJet, trailSteps, transformPath )
import Moonlight.Planar.Exact
  ( ExactPoint, ExactRational, ExactVector (..), exactHalf, exactPoint, exactPointCoordinates
  , positiveExact, translateExactPoint, unitOne, unitZero )
import Moonlight.Planar.Exhibit.EquationalGarden
  ( GardenControls (..), GardenPart (..), defaultGardenControls, gardenPartName
  , gardenPicture, leafOutline, petalOutline, gardenPlants, GardenPlant (..)
  , StemPort (..), LeafPort (..), BlossomPort (..) )
import Moonlight.Planar.Illustration (Picture, PictureAlgebra (..), foldPicture)
import Moonlight.Planar.Illustration.Motif (motifPort)
import Moonlight.Planar.Illustration.Svg
  ( renderSvg, svgOptions, svgPrecision, svgViewport )

-- These are artistic dependency and publication tests. Curve interpolation
-- laws belong to the authoring tests, not a second implementation here.
tests :: IO ()
tests = do
  baseline <- either (fail . show) pure (gardenPicture defaultGardenControls)
  opened <- either (fail . show) pure (gardenPicture defaultGardenControls { blossomOpening = unitOne })
  wind <- either (fail . show) pure (gardenPicture defaultGardenControls { gardenBreeze = unitOne })
  traverse_ checkAttachments [defaultGardenControls,
    defaultGardenControls { blossomOpening = unitOne, gardenBreeze = unitOne }]
  stillAir <- either (fail . show) pure (gardenPlants defaultGardenControls { gardenBreeze = unitZero })
  breeze <- either (fail . show) pure (gardenPlants defaultGardenControls)
  gale <- either (fail . show) pure (gardenPlants defaultGardenControls { gardenBreeze = unitOne })
  openedPlants <- either (fail . show) pure (gardenPlants defaultGardenControls { blossomOpening = unitOne })
  traverse_ socketsOnStem (stillAir <> breeze <> gale)
  traverse_ leanFollowsTangent (zip3 stillAir breeze gale)
  check "breeze leaves every blossom frame fixed"
    (all (\(a, b) -> blossomFrames a == blossomFrames b) (zip stillAir gale))
  check "opening leaves every stem trail and stem socket fixed"
    (all (\(a, b) -> gardenStemTrail a == gardenStemTrail b && stemFrames a == stemFrames b) (zip breeze openedPlants))
  check "breeze moves every leaf socket"
    (all (\(a, b) -> motifPort (gardenStem a) LowerLeafSocket /= motifPort (gardenStem b) LowerLeafSocket
      && motifPort (gardenStem a) UpperLeafSocket /= motifPort (gardenStem b) UpperLeafSocket) (zip stillAir gale))
  traverse_ (checkLayer baseline opened [Blossoms]) parts
  traverse_ (checkLayer baseline wind [Stems, Leaves]) parts
  check "petal width edit changes the real contour"
    (petalOutline unitZero /= petalOutline unitOne)
  check "petal attachment remains fixed"
    (location (petalOutline unitZero) == location (petalOutline unitOne))
  check "leaf bend changes the real contour"
    (leafOutline unitZero /= leafOutline unitOne)
  check "leaf attachment remains fixed"
    (location (leafOutline unitZero) == location (leafOutline unitOne))
  case publishedGarden of
    Left failure -> fail failure
    Right svg -> do
      check "garden retains a clipped even-odd moon cutout"
        ("fill-rule=\"evenodd\"" `isInfixOf` svg && "<clipPath" `isInfixOf` svg)
      check "garden publishes both kinds of gradient"
        ("<linearGradient" `isInfixOf` svg && "<radialGradient" `isInfixOf` svg)
      traverse_ (\part -> check ("published layer: " <> gardenPartName part)
        (gardenPartName part `isInfixOf` svg)) parts
  putStrLn "equational garden: independent controls, stable attachments and full-scene publication ok"

checkAttachments :: GardenControls -> IO ()
checkAttachments controls = do
  assemblies <- either (fail . show) pure (gardenPlants controls)
  traverse_ (\plant -> do
    check "lower leaf matches its full stem socket frame"
      (motifPort (gardenLowerLeaf plant) LeafRoot == motifPort (gardenStem plant) LowerLeafSocket)
    check "upper leaf matches its full stem socket frame"
      (motifPort (gardenUpperLeaf plant) LeafRoot == motifPort (gardenStem plant) UpperLeafSocket)
    check "blossom matches its full stem socket frame"
      (motifPort (gardenBlossom plant) BlossomRoot == motifPort (gardenStem plant) BlossomSocket)) assemblies

-- The sockets read the painted trail: each leaf origin is a knot, the start
-- of step one or two, and the blossom origin is the trail's end. The knots
-- are recomputed here from the trail's own step displacements.
socketsOnStem :: GardenPlant -> IO ()
socketsOnStem plant = do
  let knots = stemKnots (gardenStemTrail plant)
      origin port = transformPoint (affineIsoMap (motifPort (gardenStem plant) port)) (exactPoint 0 0)
  check "stem has root, two leaf knots and the flower" (length knots == 4)
  check "lower leaf socket sits on its knot" (Just (origin LowerLeafSocket) == lookupIndex 1 knots)
  check "upper leaf socket sits on its knot" (Just (origin UpperLeafSocket) == lookupIndex 2 knots)
  check "blossom socket sits on the stem's end" (Just (origin BlossomSocket) == lookupIndex 3 knots)

-- Breeze turns a leaf socket only through the stem's tangent at its knot.
-- Undoing the exact, unnormalized tangent frame there (tangent, left normal)
-- leaves the authored lean and size, scaled by the positive normalization
-- factor: across breezes those relative matrices are positive multiples of
-- one another, and their origin is the knot itself.
leanFollowsTangent :: (GardenPlant, GardenPlant, GardenPlant) -> IO ()
leanFollowsTangent (still, middle, strong) = traverse_ relation [(LowerLeafSocket, 1), (UpperLeafSocket, 2)]
 where
  relation :: (StemPort, Int) -> IO ()
  relation (port, knot) = do
    relatives <- traverse (relative port knot) [still, middle, strong]
    check "leaf lean relative to the tangent is breeze-invariant up to normalization"
      (all (uncurry positiveMultiple) (zip relatives (drop 1 relatives)))
  relative :: StemPort -> Int -> GardenPlant -> IO (ExactVector, ExactVector, ExactVector)
  relative port knot plant = do
    let trail = gardenStemTrail plant
        steps = trailSteps (locatedValue trail)
    (tangent, point) <- maybe (fail "missing stem knot") pure
      ((,) <$> (startJet <$> Seq.lookup knot steps) <*> lookupIndex knot (stemKnots trail))
    let ExactVector tx ty = tangent
        (px, py) = exactPointCoordinates point
    frame <- maybe (fail "stationary stem tangent") pure
      (affineIso2 (affine2 tangent (ExactVector (negate ty) tx) (ExactVector px py)))
    pure (affineColumns (affineIsoMap (composeAffineIso2 (inverseAffineIso2 frame)
      (motifPort (gardenStem plant) port))))
  positiveMultiple :: (ExactVector, ExactVector, ExactVector) -> (ExactVector, ExactVector, ExactVector) -> Bool
  positiveMultiple (a, b, offset) (c, d, offset') =
    offset == ExactVector 0 0 && offset' == ExactVector 0 0
      && cross a c == 0 && cross b d == 0 && dot a c > 0 && dot b d > 0
      && dot a c * dot d d == dot b d * dot c c

stemKnots :: Located OpenTrail -> [ExactPoint]
stemKnots trail = toList (Seq.scanl (\point step -> translateExactPoint point (curveStepEnd step))
  (location trail) (trailSteps (locatedValue trail)))

lookupIndex :: Int -> [a] -> Maybe a
lookupIndex index = Seq.lookup index . Seq.fromList

blossomFrames :: GardenPlant -> (AffineIso2, AffineIso2)
blossomFrames plant = (motifPort (gardenStem plant) BlossomSocket, motifPort (gardenBlossom plant) BlossomRoot)

stemFrames :: GardenPlant -> [AffineIso2]
stemFrames plant = motifPort (gardenStem plant) <$> [minBound .. maxBound]

cross :: ExactVector -> ExactVector -> ExactRational
cross (ExactVector a b) (ExactVector c d) = a * d - b * c

dot :: ExactVector -> ExactVector -> ExactRational
dot (ExactVector a b) (ExactVector c d) = a * c + b * d

parts :: [GardenPart]
parts = [NightSky, Moon, Ground, Stems, Leaves, Blossoms, Ribbon, Fireflies]

checkLayer :: Picture GardenPart -> Picture GardenPart -> [GardenPart] -> GardenPart -> IO ()
checkLayer baseline edited affected part = do
  let original = observedPaths part baseline
      candidate = observedPaths part edited
  check ("nonempty garden layer: " <> gardenPartName part) (not (null original))
  check ("semantic edit dependency: " <> gardenPartName part)
    ((original /= candidate) == (part `elem` affected))

-- A read-only projection of the existing algebra into canonical paths.
-- It preserves affine placement and sees clip boundaries; no geometry oracle
-- or alternate scene representation is introduced.
observedPaths :: GardenPart -> Picture GardenPart -> [Path]
observedPaths selected picture = foldPicture algebra picture Nothing
 where
  algebra :: PictureAlgebra GardenPart (Maybe GardenPart -> [Path])
  algebra = PictureAlgebra
    { paintSequence = \children inherited -> concatMap ($ inherited) (toList children)
    , paintFill = \_ _ contours inherited ->
        keep inherited (path (Seq.fromList (ClosedSubpath <$> NonEmpty.toList contours)))
    , paintStroke = \_ curve inherited -> keep inherited curve
    , paintClip = \_ contours child inherited ->
        keep inherited (path (Seq.fromList (ClosedSubpath <$> NonEmpty.toList contours))) <> child inherited
    , paintPlace = \frame child inherited -> transformPath frame <$> child inherited
    , paintOpacity = \_ child -> child
    , paintAnnotation = \part child _ -> child (Just part)
    }
  keep :: Maybe GardenPart -> Path -> [Path]
  keep inherited curve = if inherited == Just selected then [curve] else []

publishedGarden :: Either String String
publishedGarden = do
  width <- first show (positiveExact 1200)
  height <- first show (positiveExact 1000)
  numeric <- first show (positiveExact (exactHalf ^ (18 :: Int)))
  tolerance <- first show (positiveExact exactHalf)
  viewport <- first show (svgViewport 1200 1000 (exactPoint 0 0) width height)
  precision <- first show (svgPrecision 6 numeric)
  options <- first show (svgOptions viewport precision tolerance 20 16384)
  picture <- first show (gardenPicture defaultGardenControls)
  first show (renderSvg gardenPartName options picture)

check :: String -> Bool -> IO ()
check label condition = unless condition (fail label)