packages feed

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

module Moonlight.Planar.LayoutSpec (tests) where

import Control.Monad (unless)
import Data.Foldable (toList, traverse_)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Maybe (isNothing)
import qualified Data.Sequence as Seq
import Moonlight.Planar.Affine
import Moonlight.Planar.Curve
import Moonlight.Planar.Curve.Authoring (polygonTrail)
import Moonlight.Planar.Exact
import Moonlight.Planar.Illustration
import Moonlight.Planar.Illustration.Layout
import Moonlight.Planar.Illustration.Motif

data Port = Root

tests :: IO ()
tests = do
  let empty = geometryEnvelope (mempty :: Picture ())
      e = geometryEnvelope art
  check "empty support/bounds" (geometrySupport (ExactVector 1 0) empty == Nothing
    && isNothing (geometryBounds empty))
  check "zero direction of nonempty envelope" (geometrySupport (ExactVector 0 0) e == Just 0)
  traverse_ (envelopeLaws e) directions
  let frame = affine2 (ExactVector 2 1) (ExactVector (-1) 3) (ExactVector 7 11)
      collapse = affine2 (ExactVector 0 0) (ExactVector 0 0) (ExactVector 4 9)
      shear = affine2 (ExactVector 1 0) (ExactVector 3 1) (ExactVector 0 0)
      unshear = affine2 (ExactVector 1 0) (ExactVector (-3) 1) (ExactVector 0 0)
  traverse_ (affineLaw e frame) directions
  traverse_ (affineLaw e collapse) directions
  traverse_ (\u -> check "nested inverse shear must not rebox"
    (geometrySupport u (geometryEnvelope (place unshear (place shear art))) == geometrySupport u e)) directions
  check "singular map retains a point"
    (boundsObservation (transformGeometryEnvelope collapse e) == Just (4,9,4,9))
  let clipped = clip NonZero (polygonTrail (exactPoint 0 0 :| [exactPoint 1 0,exactPoint 0 1]) :| []) art
  traverse_ (\u -> check "clips opacity and annotations do not shrink geometry"
    (geometrySupport u (geometryEnvelope (annotate () (opacity unitZero clipped))) == geometrySupport u e)) directions
  check "stroke width and units excluded"
    (boundsObservation (geometryEnvelope (stroke (pen positiveOne LocalUnits) samplePath))
      == boundsObservation (geometryEnvelope (stroke (pen positiveTwo OutputUnits) samplePath)))
  check "empty path differs from located empty trail"
    (boundsObservation (geometryEnvelope (stroke (pen positiveOne LocalUnits) mempty)) == Nothing
      && boundsObservation (geometryEnvelope (stroke (pen positiveOne LocalUnits)
        (path (Seq.singleton (OpenSubpath (locate (exactPoint 9 4) mempty))))))
          == Just (9,4,9,4))
  traverse_ containment shapes
  traverse_ (\picture -> check "layout bounds reducer agrees with envelope bounds"
    (layoutBounds picture == boundsObservation (geometryEnvelope picture)))
    ([mempty, art, place frame art, place collapse art, place unshear (place shear art)
      , annotate () (opacity unitZero clipped), motifPicture (box 13), strokeOf mempty
      , strokeOf (path (Seq.singleton (OpenSubpath (locate (exactPoint 9 4) mempty)))) ]
      <> fmap (strokeOf . path . Seq.singleton . OpenSubpath . locate (exactPoint 0 0) . openTrail . Seq.singleton) shapes)
  traverse_ (controlLaws frame shear unshear collapse) controlFixtures
  layoutLaws
  putStrLn "layout: exact support, conservative geometry, coherent linear arrangement ok"

directions :: [ExactVector]
directions = [ExactVector 1 0, ExactVector (-1) 0, ExactVector 0 1,
  ExactVector 0 (-1), ExactVector 2 3, ExactVector (-3) 2, ExactVector 0 0]

boundsObservation :: GeometryEnvelope -> Maybe (ExactRational, ExactRational, ExactRational, ExactRational)
boundsObservation = fmap (\bounds -> (boundsMinimumX bounds, boundsMinimumY bounds,
  boundsMaximumX bounds, boundsMaximumY bounds)) . geometryBounds

envelopeLaws :: GeometryEnvelope -> ExactVector -> IO ()
envelopeLaws e u = do
  let f = geometryEnvelope (motifPicture (box 13))
      g = geometryEnvelope (motifPicture (box 2))
      support = geometrySupport u
  check "envelope identity" (support (mempty <> e) == support e && support (e <> mempty) == support e)
  check "envelope observational associativity" (support ((e <> f) <> g) == support (e <> (f <> g)))
  check "envelope observational commutativity/idempotence" (support (e <> f) == support (f <> e)
    && support (e <> e) == support e)
  check "union support maximum" (support (e <> f) == max (support e) (support f))

affineLaw :: GeometryEnvelope -> Affine2 -> ExactVector -> IO ()
affineLaw e frame u@(ExactVector ux uy) = do
  let (ExactVector a b, ExactVector c d, ExactVector tx ty) = affineColumns frame
      expected = (+ (ux * tx + uy * ty)) <$> geometrySupport (ExactVector (a*ux+b*uy) (c*ux+d*uy)) e
  check "exact affine dual support law" (geometrySupport u (transformGeometryEnvelope frame e) == expected)
  check "picture and envelope transform agree"
    (geometrySupport u (geometryEnvelope (place frame art)) == expected)

shapes :: [CurveStep]
shapes = [curveStep line (ExactVector 5 0)
  ,curveStep (quadratic (ExactVector 3 8)) (ExactVector 5 0)
  ,curveStep (cubic (ExactVector (-4) 9) (ExactVector 8 (-3))) (ExactVector 5 0)
  ,curveStep (rationalQuadratic (ExactVector 4 8) positiveOne positiveTwo) (ExactVector 5 0)]

containment :: CurveStep -> IO ()
containment step = do
  let picture :: Picture ()
      picture = stroke (pen positiveOne LocalUnits)
        (path (Seq.singleton (OpenSubpath (locate (exactPoint 0 0) (openTrail (Seq.singleton step))))))
      e = geometryEnvelope picture
      samples = fmap (\t -> evaluateStep t step) [unitZero,unitHalf,unitOne]
  traverse_ (\u -> traverse_ (\p -> check "control hull and evaluation witnesses"
    (Just (dot u p) <= geometrySupport u e)) (toList (stepControlPoints step) <> samples)) directions
 where
  dot (ExactVector x y) (ExactVector a b) = x*a+y*b

-- Bounds as layout sees them. Aligning an edge to a target moves the identity
-- port by the target minus that edge, so the edge read back is target-independent;
-- empty geometry does not move for any target.
layoutBounds :: Picture () -> Maybe (ExactRational, ExactRational, ExactRational, ExactRational)
layoutBounds picture
  | edge Horizontal unitZero 0 /= edge Horizontal unitZero 1 = Nothing
  | otherwise = Just (edge Horizontal unitZero 0, edge Vertical unitZero 0, edge Horizontal unitOne 0, edge Vertical unitOne 0)
 where
  edge :: LayoutAxis -> UnitInterval -> ExactRational -> ExactRational
  edge axis fraction target =
    let (x, y) = exactPointCoordinates (transformPoint
          (affineIsoMap (motifPort (alignMotif axis fraction target (probe picture)) Root)) (exactPoint 0 0))
     in target - case axis of
          Horizontal -> x
          Vertical -> y

probe :: Picture () -> Motif Port ()
probe picture = motif picture (const identityAffineIso2)

-- Relative controls of a trail with every shape and two constant steps. The
-- expected support is the anchor and each step's controls, enumerated by hand
-- in absolute coordinates; shared starts appear once, which cannot move support.
chain :: OpenTrail
chain = openTrail (Seq.fromList
  [ curveStep line (ExactVector 3 0)
  , curveStep (quadratic (ExactVector 1 4)) (ExactVector 2 0)
  , curveStep (cubic (ExactVector (-1) (-5)) (ExactVector 4 2)) (ExactVector 0 3)
  , curveStep line (ExactVector 0 0)
  , curveStep (quadratic (ExactVector 0 0)) (ExactVector 0 0)
  , curveStep (rationalQuadratic (ExactVector 2 (-2)) positiveTwo positiveOne) (ExactVector (-6) 1) ])

chainPoints :: NonEmpty ExactPoint
chainPoints = exactPoint 1 2 :| [exactPoint 4 2, exactPoint 5 6, exactPoint 6 2, exactPoint 5 (-3)
  , exactPoint 10 4, exactPoint 6 5, exactPoint 8 3, exactPoint 0 6]

-- The derived closing step runs from (0,6) back to the anchor through (-3,5).
closedChain :: Located ClosedTrail
closedChain = locate (exactPoint 1 2) (closeWith (quadratic (ExactVector (-3) (-1))) chain)

data ControlFixture = ControlFixture String (Picture ()) (NonEmpty ExactPoint)

controlFixtures :: [ControlFixture]
controlFixtures =
  [ ControlFixture "multi-step open trail" (strokeOf (subpaths [OpenSubpath (locate (exactPoint 1 2) chain)])) chainPoints
  , ControlFixture "closed trail with derived closing step" (fill NonZero (Solid (RGB 1 2 3)) (closedChain :| []))
      (chainPoints <> (exactPoint (-3) 5 :| []))
  , ControlFixture "located empty open trail" (strokeOf (subpaths [OpenSubpath (locate (exactPoint 9 4) mempty)]))
      (exactPoint 9 4 :| [])
  , ControlFixture "located empty closed trail" (strokeOf (subpaths [ClosedSubpath (locate (exactPoint (-2) 7) (closeWith line mempty))]))
      (exactPoint (-2) 7 :| [])
  , ControlFixture "sibling located empty and multi-step trails"
      (strokeOf (subpaths [OpenSubpath (locate (exactPoint 20 (-8)) mempty), OpenSubpath (locate (exactPoint 1 2) chain)]))
      (exactPoint 20 (-8) :| toList chainPoints) ]

-- Every fixture under identity, nested frames (each control point mapped once by
-- the composite), a nested inverse shear that must not rebox, and singular maps.
controlLaws :: Affine2 -> Affine2 -> Affine2 -> Affine2 -> ControlFixture -> IO ()
controlLaws frame shear unshear collapse (ControlFixture label picture points) = do
  let rankOne = affine2 (ExactVector 1 2) (ExactVector 2 4) (ExactVector 0 1)
  traverse_ (\(scope, scoped, expected) -> do
      let e = geometryEnvelope scoped
          bounds = Just (pointsBounds expected)
      traverse_ (\u -> check (label <> ", " <> scope <> ": support")
        (geometrySupport u e == Just (maximum (project u <$> expected)))) directions
      check (label <> ", " <> scope <> ": envelope bounds") (boundsObservation e == bounds)
      check (label <> ", " <> scope <> ": layout bounds") (layoutBounds scoped == bounds))
    [ ("identity", picture, points)
    , ("nested frames", place frame (place shear picture), transformPoint frame . transformPoint shear <$> points)
    , ("nested inverse shear", place unshear (place shear picture), points)
    , ("rank-one map", place rankOne picture, transformPoint rankOne <$> points)
    , ("collapse", place frame (place collapse picture), transformPoint frame (exactPoint 4 9) :| []) ]
 where
  project :: ExactVector -> ExactPoint -> ExactRational
  project (ExactVector x y) point = let (px, py) = exactPointCoordinates point in x * px + y * py

pointsBounds :: NonEmpty ExactPoint -> (ExactRational, ExactRational, ExactRational, ExactRational)
pointsBounds points =
  let coordinates = exactPointCoordinates <$> points
   in (minimum (fst <$> coordinates), minimum (snd <$> coordinates), maximum (fst <$> coordinates), maximum (snd <$> coordinates))

chainLayoutLaws :: IO ()
chainLayoutLaws = do
  let open = probe (strokeOf (subpaths [OpenSubpath (locate (exactPoint 1 2) chain)]))
      closed = probe (fill NonZero (Solid (RGB 1 2 3)) (closedChain :| []))
      port :: Motif Port () -> ExactPoint
      port value = transformPoint (affineIsoMap (motifPort value Root)) (exactPoint 0 0)
  check "multi-step arrangement from control bounds"
    (fmap port (toList (arrangeMotifs Horizontal 2 (Seq.fromList [open, closed, open])))
      == [exactPoint 0 0, exactPoint 15 0, exactPoint 27 0])
  check "multi-step vertical midpoint alignment"
    (port (alignMotif Vertical unitHalf 0 open) == exactPoint 0 (negate (3 * exactHalf)))

subpaths :: [Subpath] -> Path
subpaths = path . Seq.fromList

strokeOf :: Path -> Picture ()
strokeOf = stroke (pen positiveOne LocalUnits)

layoutLaws :: IO ()
layoutLaws = do
  let original = box 10
      aligned = alignMotif Horizontal unitHalf 25 original
      empty = motif mempty (const (translationAffineIso2 (ExactVector 99 17))) :: Motif Port ()
      baseline = arrangeMotifs Horizontal 5 (Seq.fromList [box 10,empty,box 20,box 3])
      wider = arrangeMotifs Horizontal 5 (Seq.fromList [box 10,empty,box 40,box 3])
      ports :: Foldable f => f (Motif Port ()) -> [ExactPoint]
      ports = fmap (\value -> transformPoint (affineIsoMap (motifPort value Root)) (exactPoint 0 0)) . toList
  check "midpoint alignment and attached port" (ports [aligned] == [exactPoint 20 0])
  check "arrangement skips empties" (ports baseline == [exactPoint 0 0,exactPoint 99 17,exactPoint 15 0,exactPoint 40 0])
  check "width edit changes only subsequent position" (ports wider == [exactPoint 0 0,exactPoint 99 17,exactPoint 15 0,exactPoint 60 0])
  check "negative gap intentional overlap" (ports (arrangeMotifs Horizontal (-3) (Seq.fromList [box 10,box 20]))
    == [exactPoint 0 0,exactPoint 7 0])
  check "vertical maximum alignment" (ports [alignMotif Vertical unitOne 30 original] == [exactPoint 0 22])
  traverse_ (\value -> case geometryBounds (geometryEnvelope (motifPicture value)) of
    Nothing -> pure ()
    Just bounds -> check "ports follow laid-out geometry"
      (transformPoint (affineIsoMap (motifPort value Root)) (exactPoint 0 0)
        == exactPoint (boundsMinimumX bounds) (boundsMinimumY bounds))) baseline
  check "empty layout identity" (ports [alignMotif Horizontal unitOne 0 empty] == ports [empty])
  chainLayoutLaws

box :: ExactRational -> Motif Port ()
box width = motif (fill NonZero (Solid (RGB 1 2 3))
  (polygonTrail (exactPoint 0 0 :| [exactPoint width 0,exactPoint width 8,exactPoint 0 8]) :| []))
  (const identityAffineIso2)

pen :: PositiveExact -> StrokeUnits -> StrokeStyle
pen width units = StrokeStyle (Solid (RGB 1 2 3)) width units SquareCap (MiterJoin miterLimitFour)

samplePath :: Path
samplePath = path (Seq.singleton (ClosedSubpath (ellipse (ExactVector 8 2) (ExactVector 3 6))))

art :: Picture ()
art = stroke (pen positiveOne LocalUnits) samplePath

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