File size: 9,942 Bytes
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
4a3d0a5
fa8df4c
 
 
 
 
 
 
 
 
 
 
4a3d0a5
 
 
 
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a3d0a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a3d0a5
 
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a3d0a5
 
fa8df4c
 
 
 
 
4a3d0a5
 
fa8df4c
 
 
 
 
 
4a3d0a5
 
 
 
 
 
 
 
fa8df4c
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""The drawing behind a slide's text: stars, glows, planets, orbit rings and constellations. Each layout variant puts
its drawing where its text is not, and the deck's seed moves it a little, so two decks never look alike while one deck
always looks the same. The same list is drawn as SVG for the page and as a picture for the PowerPoint."""

import hashlib
import math
import random
from dataclasses import dataclass, field

from app.slides.themes import H, W


@dataclass
class Shape:
    kind: str  # glow | planet | ring | band | const | stars
    cx: float = 0
    cy: float = 0
    r: float = 0
    rx: float = 0
    ry: float = 0
    rot: float = 0
    color: str = "accent"  # palette key
    opacity: float = 1.0
    width: float = 2.0
    points: list[tuple[float, float, float, float]] = field(default_factory=list)  # stars: x, y, r, opacity
    edges: list[tuple[int, int]] = field(default_factory=list)
    # ring, band: only this part of the path, in degrees round it (0 to 180 is the half nearer the viewer, below the
    # centre before the tilt). None: all of it.
    arc: tuple[float, float] | None = None
    inner: float = 0  # band: the inner edge, as a share of rx and ry (the band fills from there out to the ring)


@dataclass
class Decor:
    hot: tuple[float, float]  # where the sky is brightest
    shapes: list[Shape]


# Where a variant has room for a drawing: (x0, y0, x1, y1). None: only stars and a soft glow.
FREE = {
    "dark-space": {
        ("points", "a"): (1520, 140, 1900, 940), ("points", "c"): None, ("points", "b"): None, ("points", "d"): None,
        ("agenda", "a"): (120, 660, 820, 1000), ("agenda", "b"): None,
        ("two_column", "a"): None, ("two_column", "b"): None,
        ("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (140, 60, 760, 140),
        ("section", "b"): (1500, 60, 1900, 560), ("closing", "b"): None, ("closing", "c"): (140, 860, 960, 1060),
    },
    "light-space": {
        ("points", "a"): None, ("points", "b"): None, ("points", "c"): None, ("points", "d"): None,
        ("agenda", "a"): None, ("agenda", "b"): (120, 760, 820, 1000),
        ("two_column", "a"): None, ("two_column", "b"): None,
        ("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (100, 360, 400, 760),
        ("closing", "a"): None, ("closing", "b"): None, ("closing", "c"): None,
    },
}


def _stars(rnd: random.Random, dark: bool) -> Shape:
    n = 120 if dark else 70
    pts = []
    for _ in range(n):
        if dark:
            r, op = rnd.choice((0.8, 1.0, 1.2, 1.2, 1.6, 2.2)), round(rnd.uniform(0.18, 0.85), 2)
        else:
            r, op = rnd.choice((0.9, 1.2, 1.5, 2.0)), round(rnd.uniform(0.08, 0.26), 2)
        pts.append((rnd.uniform(0, W), rnd.uniform(0, H), r, op))
    return Shape("stars", points=pts, color="star")


def _constellation(rnd: random.Random, box: tuple[float, float, float, float], n: int = 7) -> Shape:
    x0, y0, x1, y1 = box
    pts = [(rnd.uniform(x0, x1), rnd.uniform(y0, y1), rnd.choice((4.0, 5.0, 6.0, 8.0)), 1.0) for _ in range(n)]
    pts.sort(key=lambda p: p[0])
    edges = [(i, i + 1) for i in range(n - 1)]
    if n > 4:
        edges.append((rnd.randrange(0, n - 3), rnd.randrange(n - 2, n)))
    return Shape("const", points=pts, edges=edges, color="accent", opacity=0.85)


def on_ring(ring: Shape, degrees: float) -> tuple[float, float]:
    """The point at `degrees` round a ring's path, after its tilt: where a planet sits so it is on the drawn line.
    Same maths as the renderers (SVG rotate(rot cx cy), the PowerPoint's polyline)."""
    a, rot = math.radians(degrees), math.radians(ring.rot)
    x, y = ring.rx * math.cos(a), ring.ry * math.sin(a)
    return ring.cx + x * math.cos(rot) - y * math.sin(rot), ring.cy + x * math.sin(rot) + y * math.cos(rot)


def ring_path(s: Shape, scale: float = 1.0, steps: int = 180) -> list[tuple[float, float]]:
    """Points along a ring's (scaled) path, over its arc, for the renderers to draw."""
    a0, a1 = s.arc or (0, 360)
    scaled = Shape("ring", s.cx, s.cy, rx=s.rx * scale, ry=s.ry * scale, rot=s.rot)
    return [on_ring(scaled, a0 + (a1 - a0) * k / steps) for k in range(steps + 1)]


# A ringed planet's band, from the inside out: (inner, outer, opacity) as shares of the ring. A brighter inner ring
# and a fainter outer one with a dark gap between, as Saturn's are.
BANDS = ((0.72, 0.86, 0.34), (0.89, 1.0, 0.22))
NEAR, FAR = (0, 180), (180, 360)


def ringed_planet(cx: float, cy: float, r: float, rx: float, ry: float, rot: float, opacity: float = 1.0) -> list[Shape]:
    """A planet inside its rings: the far half of the rings goes behind the planet and the near half in front, so the
    rings wrap round it instead of lying flat over its face. The far half is a little dimmer, being further away."""
    def half(arc: tuple[float, float], op: float) -> list[Shape]:
        out = [Shape("band", cx, cy, rx=rx * hi, ry=ry * hi, rot=rot, color="accent", opacity=o * op, arc=arc, inner=lo / hi)
               for lo, hi, o in BANDS]
        out.append(Shape("ring", cx, cy, rx=rx, ry=ry, rot=rot, color="accent", opacity=0.8 * op, width=2, arc=arc))
        return out
    return [*half(FAR, 0.6 * opacity), Shape("planet", cx, cy, r), *half(NEAR, opacity)]


def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor:
    """The drawing for one slide."""
    dark = theme == "dark-space"
    rnd = random.Random(int(hashlib.sha1(f"{seed}:{index}:{layout}:{variant}".encode()).hexdigest()[:12], 16))
    j = lambda span: rnd.uniform(-span, span)  # noqa: E731  (a small, seeded nudge)
    shapes = [_stars(rnd, dark)]
    hot = (W * rnd.uniform(0.25, 0.75), H * rnd.uniform(0.15, 0.5))
    key = (layout, variant)

    if dark:
        if key == ("title", "a"):
            hot = (960, 760)
            shapes += [Shape("glow", 960, 900, 900, color="accent2", opacity=0.55),
                       Shape("planet", 960 + j(60), 1080 + 560 + j(40), 820),
                       Shape("ring", 960, 860 + j(20), rx=1150, ry=150, rot=-4 + j(3), color="accent", opacity=0.45, width=2.5)]
        elif key == ("title", "b"):
            cx, cy = 1520 + j(60), 300 + j(50)
            hot = (cx, cy)
            shapes += [Shape("glow", cx, cy, 520, color="accent2", opacity=0.5),
                       *ringed_planet(cx, cy, 230 + j(25), rx=395, ry=82, rot=-18 + j(6))]
        elif key == ("title", "c"):
            hot = (1450, 540)
            shapes += [Shape("glow", 1450, 540, 620, color="accent2", opacity=0.35),
                       _constellation(rnd, (1120, 170, 1800, 910), 8)]
        elif layout == "section" and variant == "a":
            hot = (960, 540)
            shapes += [Shape("glow", 960, 540, 760, color="accent2", opacity=0.35),
                       Shape("ring", 960, 540 + j(20), rx=980, ry=260, rot=-8 + j(5), color="accent", opacity=0.3, width=2)]
        elif layout == "closing" and variant == "a":
            hot = (960, 980)
            shapes += [Shape("glow", 960, 1120, 760, color="accent2", opacity=0.5),
                       Shape("ring", 960, 1210, rx=1080, ry=880, rot=0, color="accent", opacity=0.35, width=2.5)]
        else:
            corner = rnd.choice(((0, 0), (W, 0), (0, H), (W, H)))
            shapes.append(Shape("glow", corner[0], corner[1], 700, color="accent2", opacity=0.32))
            hot = (corner[0] * 0.8 + W * 0.1, corner[1] * 0.8 + H * 0.1)
            free = FREE["dark-space"].get(key)
            if free:
                x0, y0, x1, y1 = free
                r = min(x1 - x0, y1 - y0) * rnd.uniform(0.26, 0.34)
                cx, cy = rnd.uniform(x0 + r, x1 - r), rnd.uniform(y0 + r, y1 - r)
                shapes += [Shape("glow", cx, cy, r * 2.2, color="accent2", opacity=0.35),
                           *ringed_planet(cx, cy, r, rx=r * 1.8, ry=r * 0.38, rot=-16 + j(10), opacity=0.9)]
    else:
        ring = lambda cx, cy, r, op=0.3: Shape("ring", cx, cy, rx=r, ry=r, color="accent2", opacity=op, width=2)  # noqa: E731
        if key == ("title", "a"):
            cx, cy = 2010 + j(40), 540 + j(60)
            hot = (1500, 540)
            orbit = ring(cx, cy, 540, 0.24)
            shapes += [ring(cx, cy, 360), orbit, ring(cx, cy, 740, 0.18), Shape("planet", *on_ring(orbit, 200 + j(20)), 34)]
        elif key == ("title", "b"):
            hot = (1500, 820)
            shapes += [Shape("glow", 1560, 840, 520, color="accent", opacity=0.12),
                       _constellation(rnd, (1360, 660, 1800, 980), 7)]
        elif key == ("title", "c"):
            hot = (960, 540)
            rot = -10 + j(4)  # one tilt for both, so the rings stay concentric
            orbit = Shape("ring", 960, 520, rx=900, ry=330, rot=rot, color="accent2", opacity=0.28, width=2.5)
            shapes += [orbit, Shape("ring", 960, 520, rx=1040, ry=400, rot=rot, color="accent2", opacity=0.14, width=2),
                       Shape("planet", *on_ring(orbit, rnd.choice((20, 160, 200, 340)) + j(10)), 30)]
        elif key in (("section", "a"), ("section", "b")):
            cx, toward = (1960, 180) if variant == "a" else (-40, 0)  # the planet sits on the side facing the text
            orbit = ring(cx, 560, 460, 0.22)
            shapes += [ring(cx, 560, 300), orbit, ring(cx, 560, 640, 0.15), Shape("planet", *on_ring(orbit, toward + j(10)), 26)]
        elif key == ("closing", "c"):
            hot = (960, 860)
            shapes += [Shape("ring", 960, 540, rx=1000, ry=470, rot=6 + j(4), color="accent2", opacity=0.2, width=2.5)]
        else:
            corner = rnd.choice(((W + 60, -60), (W + 60, H + 60), (-60, H + 60)))
            shapes += [ring(corner[0], corner[1], 260, 0.22), ring(corner[0], corner[1], 400, 0.15)]
            free = FREE["light-space"].get(key)
            if free:
                shapes.append(_constellation(rnd, free, 5))
    return Decor(hot, shapes)