packages feed

moonlight-planar-1.1.0.0: docs/moonblade/Moonlight/Planar/Exhibit/Moonblade/Crescent.hs

module Moonlight.Planar.Exhibit.Moonblade.Crescent (frames, solid) where

import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NonEmpty
import Moonlight.Planar.Exhibit.Geometry (ShapeError, Triangle (..), solidObj, triangulateShape)
import Moonlight.Planar.Exhibit.Svg (circle, polygon, scalar)
import Moonlight.Planar.Point (Point (..))
import Text.Printf (printf)

data Material = Moonsteel | PaleGold

solid :: Either ShapeError String
solid = do
  blade <- triangulateShape (bladeOutline :| [fullerOutline]) bladeSeeds
  guard <- triangulateShape (guardOutline :| []) guardSeeds
  pure $ "# Vesper: native crescent blade and guard, metres, Z up.\n"
    <> solidObj "Blade" bladeThickness blade
    <> solidObj "Guard" (const 0.012) guard
 where
  bladeThickness :: Point -> Double
  bladeThickness (Point x y) =
    let t = max 0 (min 1 (negate y / 900))
        across = abs ((x - bladeCenter t) / max 1 (bladeWidth t))
        edge = 0.00035 + 0.00085 * min 1 ((1 - t) / 0.05)
        tipTaper = min 1 ((1 - t) / 0.12)
     in edge + 0.009 * (1 - min 1 across) * (1 - 0.4 * t) * tipTaper

-- One immutable native mesh, sampled at 80 phases of a 6.4-second loop.
-- Only the rigid pose and illumination vary; the silhouette never deforms.
frames :: Either ShapeError [(FilePath, String)]
frames = do
  blade <- triangulateShape (bladeOutline :| [fullerOutline]) bladeSeeds
  guard <- triangulateShape (guardOutline :| []) guardSeeds
  let bladeFacets = foldMap (uncurry (renderFacet Moonsteel)) (zip [0 ..] blade)
      guardFacets = foldMap (uncurry (renderFacet PaleGold)) (zip [0 ..] guard)
      clip = "<defs><clipPath id=\"blade-clip\">"
        <> foldMap (\(Triangle a b c) -> polygon "" [a, b, c]) blade
        <> "</clipPath></defs>"
      render phase =
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"900\" viewBox=\"0 0 1600 1200\">"
        <> "<title>Vesper - the crescent moonblade</title><desc>A rigid hovering moonblade with pulsing runes and a traveling reflection. Native constrained Delaunay facets preserve the pierced fuller.</desc>"
        <> definitions <> clip <> background phase
        <> "<g transform=\"translate(" <> scalar (525 + 5 * sin (2 * pi * phase))
        <> " " <> scalar (870 - 7 * (1 - cos (2 * pi * phase)))
        <> ") rotate(" <> scalar (40 + 0.6 * sin (2 * pi * phase)) <> ")\">"
        <> bladeAura phase <> hilt <> bladeFacets <> bladeEdges <> lightSweep phase
        <> polygon "fill=\"none\" stroke=\"#a6dbe9\" stroke-width=\"1.1\"" (NonEmpty.toList fullerOutline)
        <> bladeEngraving phase <> guardFacets
        <> polygon "fill=\"none\" stroke=\"#f5e4b7\" stroke-width=\"1.6\"" (NonEmpty.toList guardOutline)
        <> guardFiligree <> fittings
        <> "</g>" <> typography <> "</svg>"
  pure $ fmap (\index -> (printf "frame-%03d.svg" index, render (fromIntegral index / 80))) [0 .. 79 :: Int]

bladePoint :: Double -> Double -> Point
bladePoint side t = quantize $
  Point (bladeCenter t + side * bladeWidth t) (-900 * t)

bladeCenter :: Double -> Double
bladeCenter t = 12 * t + 40 * t * t

bladeWidth :: Double -> Double
bladeWidth t = (62 - 22 * t) * if t <= 0.76 then 1 else ((1 - t) / 0.24) ** 0.72

bladeOutline :: NonEmpty Point
bladeOutline = bladePoint (-1) 0 :|
  (fmap (bladePoint (-1) . fraction) [1 .. 48] <> fmap (bladePoint 1 . fraction) [47, 46 .. 0])

fullerPoint :: Double -> Double -> Point
fullerPoint side u =
  let t = 0.12 + 0.22 * u
   in quantize (Point (bladeCenter t + side * 7 * sin (pi * u)) (-900 * t))

fullerOutline :: NonEmpty Point
fullerOutline = fullerPoint (-1) 0 :|
  (fmap (fullerPoint (-1) . fraction) [1 .. 48] <> fmap (fullerPoint 1 . fraction) [47, 46 .. 1])

bladeSeeds :: [Point]
bladeSeeds = fmap
  (\index -> bladePoint (0.77 * sin (fromIntegral index * 2.39996)) ((fromIntegral index + 0.5) / 118))
  [0 .. 117 :: Int]

guardPoint :: Double -> Double -> Point
guardPoint height t = quantize (Point (-168 + 336 * t) (-75 + height * sin (pi * t)))

guardOutline :: NonEmpty Point
guardOutline = guardPoint 135 0 :|
  (fmap (guardPoint 135 . fraction) [1 .. 48] <> fmap (guardPoint 88 . fraction) [47, 46 .. 1])

guardSeeds :: [Point]
guardSeeds = fmap
  (\index -> guardPoint (110 + 13 * sin (fromIntegral index * 2.4)) ((fromIntegral index + 0.5) / 35))
  [0 .. 34 :: Int]

fraction :: Int -> Double
fraction index = fromIntegral index / 48

quantize :: Point -> Point
quantize (Point x y) = Point (snap x) (snap y)
 where
  snap :: Double -> Double
  snap value = fromIntegral (round (1024 * value) :: Integer) / 1024

renderFacet :: Material -> Int -> Triangle -> String
renderFacet material index (Triangle a@(Point ax ay) b@(Point bx by) c@(Point cx cy)) =
  let t = negate (ay + by + cy) / 2700
      across = ((ax + bx + cx) / 3 - bladeCenter t) / max 1 (bladeWidth (max 0 (min 1 t)))
      shimmer = 0.5 + 0.5 * sin (fromIntegral index * 1.719)
      light = max 0 (min 1 (0.35 + 0.23 * shimmer + 0.3 * across))
      color = case material of
        Moonsteel -> rgb (18 + 114 * light) (42 + 162 * light) (64 + 180 * light)
        PaleGold -> rgb (102 + 137 * shimmer) (92 + 128 * shimmer) (69 + 112 * shimmer)
      stroke = case material of Moonsteel -> "#a6d9e8"; PaleGold -> "#e9d9af"
   in polygon ("fill=\"" <> color <> "\" stroke=\"" <> stroke
      <> "\" stroke-width=\"0.48\" stroke-opacity=\"0.24\"") [a, b, c]

rgb :: Double -> Double -> Double -> String
rgb red green blue = printf "#%02x%02x%02x" (round red :: Int) (round green :: Int) (round blue :: Int)

bladeEdges :: String
bladeEdges =
  polygon "fill=\"url(#silver)\"" (fmap (bladePoint 1 . fraction) [0 .. 48] <> fmap (bladePoint 0.78 . fraction) [47, 46 .. 0])
  <> polygon "fill=\"#d8f4f1\" fill-opacity=\"0.75\"" (fmap (bladePoint (-1) . fraction) [0 .. 48] <> fmap (bladePoint (-0.94) . fraction) [47, 46 .. 0])
  <> polygon "fill=\"#d3f1ed\" fill-opacity=\"0.55\"" (fmap (bladePoint 0.08 . fraction) [18 .. 48] <> fmap (bladePoint 0.04 . fraction) [47, 46 .. 18])
  <> polygon "fill=\"none\" stroke=\"#deffff\" stroke-width=\"1.4\"" (NonEmpty.toList bladeOutline)

bladeAura :: Double -> String
bladeAura phase = "<g filter=\"url(#aura)\" opacity=\""
  <> scalar (0.85 + 0.15 * cos (2 * pi * phase)) <> "\">"
  <> polygon "fill=\"none\" stroke=\"#56cbf7\" stroke-width=\"11\" stroke-opacity=\"0.45\"" (NonEmpty.toList bladeOutline)
  <> "</g>"

-- The light is clipped to the selected triangles, so the real hole stays empty.
-- The band lies entirely outside the blade at both ends of the cycle.
lightSweep :: Double -> String
lightSweep phase = "<g clip-path=\"url(#blade-clip)\"><rect x=\"-100\" y=\""
  <> scalar (100 - 1250 * phase)
  <> "\" width=\"300\" height=\"200\" fill=\"url(#reflection)\"/></g>"

bladeEngraving :: Double -> String
bladeEngraving phase = foldMap rune [0 .. 8 :: Int]
 where
  rune :: Int -> String
  rune index =
    let t = 0.43 + fromIntegral index * 0.047
        Point x y = bladePoint (-0.36) t
        marking = case index `mod` 3 of
          0 -> "M -5 -6 L 0 0 L -5 6 M 0 0 L 7 0 M 3 -4 L 7 0 L 3 4"
          1 -> "M 0 -7 L 0 7 M -5 -3 L 0 2 L 5 -3 M -3 7 L 3 7"
          _ -> "M -5 0 L 0 -7 L 5 0 L 0 7 Z M -7 0 L 7 0"
     in "<path transform=\"translate(" <> scalar x <> " " <> scalar y <> ") rotate("
        <> scalar (atan ((12 + 80 * t) / 900) * 180 / pi) <> ")\" d=\"" <> marking
        <> "\" fill=\"none\" stroke=\"#e4e7bd\" stroke-width=\"1.2\" stroke-linecap=\"round\" opacity=\""
        <> scalar (0.72 + 0.23 * cos (2 * pi * phase - fromIntegral index * 0.7)) <> "\"/>"

hilt :: String
hilt =
  "<path d=\"M -24 21 L 24 21 L 20 193 L 0 214 L -20 193 Z\" fill=\"url(#grip)\" stroke=\"#899da7\" stroke-width=\"2\"/>"
  <> foldMap binding [0 .. 11 :: Int]
  <> "<path d=\"M -24 29 H 24 M -22 40 H 22 M -20 182 H 20 M -20 192 H 20\" stroke=\"#d7cba8\" stroke-width=\"5\"/>"
 where
  binding :: Int -> String
  binding index =
    let y = 48 + fromIntegral index * 11
     in "<path d=\"M -22 " <> scalar y <> " L 21 " <> scalar (y + 13)
        <> " M 21 " <> scalar y <> " L -22 " <> scalar (y + 13)
        <> "\" stroke=\"#55666e\" stroke-width=\"2\"/><path d=\"M -20 " <> scalar y
        <> " L 20 " <> scalar (y + 13) <> "\" stroke=\"#b4b1a0\" stroke-width=\"0.5\"/>"

guardFiligree :: String
guardFiligree =
  "<path d=\"M -146 -48 Q -85 42 0 36 Q 85 42 146 -48 M -125 -26 Q -68 47 -22 35 M 125 -26 Q 68 47 22 35\" fill=\"none\" stroke=\"#f8edc7\" stroke-width=\"1\"/>"
  <> foldMap (\index -> circle (guardPoint 112 (fromIntegral index / 16)) 1.8 "fill=\"#f5e3b2\" stroke=\"#6e664f\" stroke-width=\"0.5\"") [1 .. 15 :: Int]

fittings :: String
fittings =
  "<path d=\"M 0 -43 L 31 2 L 0 50 L -31 2 Z\" fill=\"#132d3c\" stroke=\"#ddd0a9\" stroke-width=\"3\"/>"
  <> "<path d=\"M 0 -28 L 18 2 L 0 32 L -18 2 Z\" fill=\"url(#gem)\" stroke=\"#b4e6ea\" stroke-width=\"1\"/>"
  <> "<path d=\"M 0 -28 L 0 32 M -18 2 H 18\" stroke=\"#deffff\" stroke-opacity=\"0.5\" stroke-width=\"0.7\"/>"
  <> circle (Point 0 226) 34 "fill=\"#101e2a\" stroke=\"#cec39e\" stroke-width=\"3\""
  <> circle (Point 0 226) 28 "fill=\"url(#gem)\" stroke=\"#719bb0\" stroke-width=\"0.8\""
  <> "<path d=\"M 9 202 A 25 25 0 1 0 9 250 A 27 27 0 0 1 9 202 Z\" fill=\"#e7e7cd\"/>"
  <> "<path d=\"M 0 261 L 0 277 M -4 269 L 0 277 L 4 269\" stroke=\"#cec39e\" fill=\"none\" stroke-width=\"2\"/>"

background :: Double -> String
background phase =
  "<rect width=\"1600\" height=\"1200\" fill=\"url(#night)\"/>"
  <> "<g opacity=\"" <> scalar (0.85 + 0.15 * cos (2 * pi * phase)) <> "\">"
  <> circle (Point 1040 337) 379 "fill=\"url(#moon-halo)\""
  <> circle (Point 1040 337) 264 "fill=\"url(#moon)\" stroke=\"#8bafbd\" stroke-opacity=\"0.16\" stroke-width=\"0.8\""
  <> "</g><g fill=\"none\" stroke=\"#7593a1\" stroke-width=\"0.55\" opacity=\"0.24\"><circle cx=\"1040\" cy=\"337\" r=\"302\"/><circle cx=\"1040\" cy=\"337\" r=\"310\" stroke-dasharray=\"1 9\"/><path d=\"M 718 139 A 379 379 0 0 1 1349 557\"/></g>"
  <> foldMap star [0 .. 219 :: Int]
  <> "<path d=\"M 1166 135 V 191 M 1138 163 H 1194\" stroke=\"#c4eff2\" stroke-width=\"0.8\" opacity=\""
  <> scalar (0.65 + 0.15 * cos (2 * pi * phase)) <> "\"/>"
 where
  star :: Int -> String
  star index = circle
    (Point (45 + fromIntegral ((index * 947 + 71) `mod` 1510)) (45 + fromIntegral ((index * 389 + 173) `mod` 1110)))
    (if index `mod` 17 == 0 then 1.25 else 0.6)
    ("fill=\"#aec7d4\" opacity=\"" <> scalar (0.28 + 0.1 * cos (2 * pi * phase + fromIntegral index * 1.71)) <> "\"")

definitions :: String
definitions = "<defs>"
  <> "<radialGradient id=\"night\" cx=\"68%\" cy=\"28%\" r=\"85%\"><stop stop-color=\"#1e394c\"/><stop offset=\"0.53\" stop-color=\"#0b1927\"/><stop offset=\"1\" stop-color=\"#050b14\"/></radialGradient>"
  <> "<radialGradient id=\"moon-halo\"><stop stop-color=\"#86b2cf\" stop-opacity=\"0.15\"/><stop offset=\"1\" stop-color=\"#86b2cf\" stop-opacity=\"0\"/></radialGradient>"
  <> "<radialGradient id=\"moon\" cx=\"33%\" cy=\"25%\" r=\"80%\"><stop stop-color=\"#7c9daa\" stop-opacity=\"0.30\"/><stop offset=\"0.6\" stop-color=\"#58778c\" stop-opacity=\"0.13\"/><stop offset=\"1\" stop-color=\"#182b3c\" stop-opacity=\"0.1\"/></radialGradient>"
  <> "<linearGradient id=\"silver\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0.4\"><stop stop-color=\"#7193a4\"/><stop offset=\"0.35\" stop-color=\"#d5e7e7\"/><stop offset=\"0.65\" stop-color=\"#f0fcf0\"/><stop offset=\"1\" stop-color=\"#8ebccd\"/></linearGradient>"
  <> "<linearGradient id=\"reflection\" x1=\"0%\" y1=\"0%\" x2=\"0%\" y2=\"100%\"><stop stop-color=\"#d9ffff\" stop-opacity=\"0\"/><stop offset=\"0.35\" stop-color=\"#a4edff\" stop-opacity=\"0.08\"/><stop offset=\"0.5\" stop-color=\"#edffff\" stop-opacity=\"0.42\"/><stop offset=\"0.65\" stop-color=\"#a4edff\" stop-opacity=\"0.08\"/><stop offset=\"1\" stop-color=\"#d9ffff\" stop-opacity=\"0\"/></linearGradient>"
  <> "<linearGradient id=\"grip\"><stop stop-color=\"#101822\"/><stop offset=\"0.45\" stop-color=\"#344455\"/><stop offset=\"1\" stop-color=\"#080f18\"/></linearGradient>"
  <> "<radialGradient id=\"gem\" cx=\"30%\" cy=\"20%\"><stop stop-color=\"#cff6ed\"/><stop offset=\"0.35\" stop-color=\"#5397b7\"/><stop offset=\"1\" stop-color=\"#132a52\"/></radialGradient>"
  <> "<filter id=\"aura\" x=\"-50%\" y=\"-20%\" width=\"200%\" height=\"140%\"><feGaussianBlur stdDeviation=\"10\"/></filter></defs>"

typography :: String
typography =
  "<g fill=\"#d4dcd6\"><text x=\"86\" y=\"99\" font-family=\"Helvetica, sans-serif\" font-size=\"12\" letter-spacing=\"4\">MOONLIGHT / RELIQUARY No. 01</text>"
  <> "<path d=\"M 86 123 H 416\" stroke=\"#758891\" stroke-width=\"0.7\"/>"
  <> "<text x=\"79\" y=\"258\" font-family=\"Georgia, serif\" font-size=\"102\" letter-spacing=\"-4\">Vesper.</text>"
  <> "<text x=\"88\" y=\"301\" font-family=\"Helvetica, sans-serif\" font-size=\"15\" letter-spacing=\"7\">THE MOONBLADE</text></g>"
  <> "<g fill=\"#91a9b6\" font-family=\"Georgia, serif\" font-size=\"20\" font-style=\"italic\"><text x=\"88\" y=\"369\">An edge of borrowed light.</text><text x=\"88\" y=\"400\">A silence given shape.</text></g>"
  <> "<g fill=\"#94a7ad\" font-family=\"Helvetica, sans-serif\" font-size=\"11\" letter-spacing=\"2\"><text x=\"88\" y=\"1138\">MOONSTEEL / PALE GOLD / MIDNIGHT GLASS</text><text x=\"1512\" y=\"1138\" text-anchor=\"end\">FORGED IN PLANAR GEOMETRY</text></g>"