packages feed

moonlight-planar-1.1.0.0: src-illustration/Moonlight/Planar/Illustration/Layout.hs

-- | Conservative geometry layout, not painted-ink bounds. Stroke centerlines
-- contribute geometry; width, clipping and opacity do not change this view.
module Moonlight.Planar.Illustration.Layout
  ( GeometryEnvelope
  , geometryEnvelope
  , transformGeometryEnvelope
  , geometrySupport
  , geometryBounds
  , LayoutAxis (..)
  , alignMotif
  , arrangeMotifs
  ) where

import Data.Foldable (toList)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Semigroup (Max (..))
import Data.Sequence (Seq, ViewL (..))
import qualified Data.Sequence as Seq
import Data.Traversable (mapAccumL)
import Moonlight.Planar.Affine
  ( Affine2, composeAffine2, identityAffine2, transformPoint, translationAffineIso2 )
import Moonlight.Planar.Curve
  ( CurveStep, Subpath (..), closedTrailSteps, curveStepEnd, locatedValue
  , location, pathSubpaths, stepControlPoints, trailSteps )
import Moonlight.Planar.Exact
  ( ExactBounds, ExactPoint, ExactRational, ExactVector (..), UnitInterval
  , boundsMaximumX, boundsMaximumY, boundsMinimumX, boundsMinimumY
  , exactPointCoordinates, exactPointsBounds, translateExactPoint, unitIntervalValue )
import Moonlight.Planar.Illustration (Picture, PictureAlgebra (..), foldPicture)
import Moonlight.Planar.Illustration.Motif (Motif, motifPicture, transformMotif)

-- Finite control support. Representation equality is not envelope equality.
newtype GeometryEnvelope = GeometryEnvelope (Seq ExactPoint)

instance Semigroup GeometryEnvelope where
  GeometryEnvelope a <> GeometryEnvelope b = GeometryEnvelope (a <> b)

instance Monoid GeometryEnvelope where
  mempty = GeometryEnvelope Seq.empty

-- | Each control point is transformed once after accumulating enclosing maps.
-- A located empty trail retains its anchor; an empty path/picture stays empty.
geometryEnvelope :: Picture part -> GeometryEnvelope
geometryEnvelope picture = foldPicture algebra picture identityAffine2
 where
  algebra :: PictureAlgebra part (Affine2 -> GeometryEnvelope)
  algebra = PictureAlgebra
    { paintSequence = \children frame -> foldMap ($ frame) children
    , paintFill = \_ _ contours frame -> foldMap (subpathEnvelope frame . ClosedSubpath) contours
    , paintStroke = \_ curve frame -> foldMap (subpathEnvelope frame) (pathSubpaths curve)
    , paintClip = \_ _ child -> child
    , paintPlace = \local child outer -> child (composeAffine2 outer local)
    , paintOpacity = \_ child -> child
    , paintAnnotation = \_ child -> child
    }

subpathEnvelope :: Affine2 -> Subpath -> GeometryEnvelope
subpathEnvelope frame subpath = case subpath of
  OpenSubpath value -> controlsEnvelope frame (location value) (trailSteps (locatedValue value))
  ClosedSubpath value -> controlsEnvelope frame (location value) (closedTrailSteps (locatedValue value))

controlsEnvelope :: Affine2 -> ExactPoint -> Seq CurveStep -> GeometryEnvelope
controlsEnvelope frame anchor steps = GeometryEnvelope $
  Seq.singleton (transformPoint frame anchor) <> foldMap id (snd (mapAccumL advance anchor steps))
 where
  advance :: ExactPoint -> CurveStep -> (ExactPoint, Seq ExactPoint)
  advance origin step =
    ( translateExactPoint origin (curveStepEnd step)
    , Seq.fromList (toList (transformPoint frame . translateExactPoint origin <$> stepControlPoints step))
    )

transformGeometryEnvelope :: Affine2 -> GeometryEnvelope -> GeometryEnvelope
transformGeometryEnvelope frame (GeometryEnvelope points) =
  GeometryEnvelope (transformPoint frame <$> points)

-- | Maximum unnormalised directional projection; Nothing is the empty case.
geometrySupport :: ExactVector -> GeometryEnvelope -> Maybe ExactRational
geometrySupport (ExactVector x y) (GeometryEnvelope points) =
  getMax <$> foldMap (Just . Max . project) points
 where
  project :: ExactPoint -> ExactRational
  project point = let (px, py) = exactPointCoordinates point in x * px + y * py

geometryBounds :: GeometryEnvelope -> Maybe ExactBounds
geometryBounds (GeometryEnvelope points) = case Seq.viewl points of
  EmptyL -> Nothing
  first :< rest -> Just (exactPointsBounds (first :| toList rest))

data LayoutAxis = Horizontal | Vertical
  deriving stock (Eq, Ord, Show)

-- | Align a minimum (0), midpoint (1/2), maximum (1), or interpolated edge.
-- Empty geometry remains unchanged; the perpendicular coordinate is retained.
alignMotif :: LayoutAxis -> UnitInterval -> ExactRational -> Motif port part -> Motif port part
alignMotif axis fraction target value = case geometryBounds (geometryEnvelope (motifPicture value)) of
  Nothing -> value
  Just bounds ->
    let (lo, hi) = axisInterval axis bounds
        q = unitIntervalValue fraction
     in shiftMotif axis (target - ((1 - q) * lo + q * hi)) value

-- | Measure each child once, preserving order and the first nonempty position.
-- Negative gaps overlap. Empty geometry neither consumes nor advances a gap.
arrangeMotifs :: LayoutAxis -> ExactRational -> Seq (Motif port part) -> Seq (Motif port part)
arrangeMotifs axis gap values = snd (mapAccumL arrange Nothing (measure <$> values))
 where
  measure :: Motif port part -> (Motif port part, Maybe (ExactRational, ExactRational))
  measure value = (value, axisInterval axis <$> geometryBounds (geometryEnvelope (motifPicture value)))
  arrange :: Maybe ExactRational -> (Motif port part, Maybe (ExactRational, ExactRational))
          -> (Maybe ExactRational, Motif port part)
  arrange edge (value, Nothing) = (edge, value)
  arrange Nothing (value, Just (_, hi)) = (Just hi, value)
  arrange (Just edge) (value, Just (lo, hi)) =
    let delta = edge + gap - lo
     in (Just (hi + delta), shiftMotif axis delta value)

axisInterval :: LayoutAxis -> ExactBounds -> (ExactRational, ExactRational)
axisInterval Horizontal bounds = (boundsMinimumX bounds, boundsMaximumX bounds)
axisInterval Vertical bounds = (boundsMinimumY bounds, boundsMaximumY bounds)

shiftMotif :: LayoutAxis -> ExactRational -> Motif port part -> Motif port part
shiftMotif axis delta = transformMotif (translationAffineIso2 displacement)
 where
  displacement = case axis of
    Horizontal -> ExactVector delta 0
    Vertical -> ExactVector 0 delta