cloudspin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudspin/__init__.py +3 -0
- cloudspin/__main__.py +4 -0
- cloudspin/app.py +497 -0
- cloudspin/canvas.py +321 -0
- cloudspin/cli.py +98 -0
- cloudspin/color.py +78 -0
- cloudspin/config.py +105 -0
- cloudspin/library.py +140 -0
- cloudspin/mood.py +245 -0
- cloudspin/player.py +447 -0
- cloudspin/screens.py +130 -0
- cloudspin/sky.py +829 -0
- cloudspin/songlook.py +116 -0
- cloudspin/spectrum.py +83 -0
- cloudspin/themes.py +100 -0
- cloudspin/vinyl.py +182 -0
- cloudspin/weather.py +310 -0
- cloudspin/widgets.py +750 -0
- cloudspin-0.1.0.dist-info/METADATA +206 -0
- cloudspin-0.1.0.dist-info/RECORD +23 -0
- cloudspin-0.1.0.dist-info/WHEEL +4 -0
- cloudspin-0.1.0.dist-info/entry_points.txt +2 -0
- cloudspin-0.1.0.dist-info/licenses/LICENSE +21 -0
cloudspin/songlook.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The look of a song: a background and two accents pulled from its cover art.
|
|
2
|
+
|
|
3
|
+
Songs without art still get a look of their own, derived from the album or
|
|
4
|
+
title, so the deck changes colour whenever the music does.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import colorsys
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .color import RGB
|
|
15
|
+
|
|
16
|
+
THUMB = 24 # the cover is kept as a THUMB x THUMB swatch for the record label
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SongLook:
|
|
21
|
+
background: RGB
|
|
22
|
+
accent: RGB
|
|
23
|
+
accent2: RGB
|
|
24
|
+
art: bytes | None = None # THUMB * THUMB * 3 RGB bytes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rgb(h: float, s: float, v: float) -> RGB:
|
|
28
|
+
r, g, b = colorsys.hsv_to_rgb(h % 1.0, max(0.0, min(1.0, s)), max(0.0, min(1.0, v)))
|
|
29
|
+
return (int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hsv(c: RGB) -> tuple[float, float, float]:
|
|
33
|
+
return colorsys.rgb_to_hsv(c[0] / 255.0, c[1] / 255.0, c[2] / 255.0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hue_gap(a: float, b: float) -> float:
|
|
37
|
+
d = abs(a - b) % 1.0
|
|
38
|
+
return min(d, 1.0 - d)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _as_accent(c: RGB, floor: float = 0.84) -> RGB:
|
|
42
|
+
"""Keep a colour's hue but make it bright and calm enough to read on a dark deck."""
|
|
43
|
+
h, s, v = _hsv(c)
|
|
44
|
+
return _rgb(h, min(max(s, 0.36), 0.80), min(max(v, floor), 0.98))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def from_text(text: str) -> SongLook:
|
|
48
|
+
digest = hashlib.sha1(text.encode("utf-8", "replace")).digest()
|
|
49
|
+
hue = digest[0] / 256.0 + digest[1] / 65536.0
|
|
50
|
+
turn = 0.08 + (digest[2] / 255.0) * 0.10
|
|
51
|
+
return SongLook(
|
|
52
|
+
background=_rgb(hue, 0.50, 0.125),
|
|
53
|
+
accent=_rgb(hue, 0.52, 0.96),
|
|
54
|
+
accent2=_rgb(hue + turn, 0.58, 0.92),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def from_art(data: bytes) -> SongLook | None:
|
|
59
|
+
"""Read a cover image and pick colours from it. None if it can't be decoded."""
|
|
60
|
+
try:
|
|
61
|
+
from PIL import Image
|
|
62
|
+
except ImportError:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
image = Image.open(io.BytesIO(data))
|
|
66
|
+
image.draft("RGB", (160, 160))
|
|
67
|
+
image = image.convert("RGB")
|
|
68
|
+
art = image.resize((THUMB, THUMB), Image.Resampling.LANCZOS).tobytes()
|
|
69
|
+
small = image.resize((40, 40), Image.Resampling.BILINEAR)
|
|
70
|
+
quantized = small.quantize(colors=8, method=Image.Quantize.MEDIANCUT)
|
|
71
|
+
palette = quantized.getpalette() or []
|
|
72
|
+
counts = quantized.getcolors() or []
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
total = float(sum(count for count, _ in counts)) or 1.0
|
|
76
|
+
swatches: list[tuple[float, RGB]] = []
|
|
77
|
+
for count, index in counts:
|
|
78
|
+
rgb = tuple(palette[index * 3 : index * 3 + 3])
|
|
79
|
+
if len(rgb) == 3:
|
|
80
|
+
swatches.append((count / total, (int(rgb[0]), int(rgb[1]), int(rgb[2]))))
|
|
81
|
+
if not swatches:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def vivid(entry: tuple[float, RGB]) -> float:
|
|
85
|
+
share, colour = entry
|
|
86
|
+
_, s, v = _hsv(colour)
|
|
87
|
+
if s < 0.16 or v < 0.16:
|
|
88
|
+
return 0.0
|
|
89
|
+
return (share**0.35) * s * (0.35 + v)
|
|
90
|
+
|
|
91
|
+
dominant = max(swatches, key=lambda entry: entry[0])[1]
|
|
92
|
+
ranked = sorted(swatches, key=vivid, reverse=True)
|
|
93
|
+
if vivid(ranked[0]) <= 0.0:
|
|
94
|
+
# A black-and-white cover: stay monochrome.
|
|
95
|
+
return SongLook(background=(14, 14, 17), accent=(228, 230, 236), accent2=(152, 158, 172), art=art)
|
|
96
|
+
first = ranked[0][1]
|
|
97
|
+
hue = _hsv(first)[0]
|
|
98
|
+
second = next(
|
|
99
|
+
(colour for entry in ranked[1:] if vivid(entry) > 0.0 and _hue_gap(_hsv((colour := entry[1]))[0], hue) >= 0.07),
|
|
100
|
+
None,
|
|
101
|
+
)
|
|
102
|
+
accent = _as_accent(first)
|
|
103
|
+
accent2 = _as_accent(second, 0.78) if second is not None else _rgb(hue + 0.09, 0.58, 0.90)
|
|
104
|
+
dh, ds, dv = _hsv(dominant)
|
|
105
|
+
if ds < 0.14:
|
|
106
|
+
dh, ds = hue, 0.22
|
|
107
|
+
background = _rgb(dh, min(ds, 0.62) * 0.9, 0.125)
|
|
108
|
+
return SongLook(background=background, accent=accent, accent2=accent2, art=art)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def look_for(title: str, artist: str, album: str, art: bytes | None) -> SongLook:
|
|
112
|
+
if art:
|
|
113
|
+
look = from_art(art)
|
|
114
|
+
if look is not None:
|
|
115
|
+
return look
|
|
116
|
+
return from_text(f"{artist}\n{album}" if (artist or album) else title)
|
cloudspin/spectrum.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The bars beside the record.
|
|
2
|
+
|
|
3
|
+
This is a generative spectrum, not an FFT of the audio: it is driven by the
|
|
4
|
+
play state, the volume and a tempo picked per song, so it moves like music
|
|
5
|
+
without needing access to the sound card.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Spectrum:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.levels: list[float] = []
|
|
17
|
+
self.peaks: list[float] = []
|
|
18
|
+
self.energy = 0.0
|
|
19
|
+
self._t = 0.0
|
|
20
|
+
self._tempo = 110.0
|
|
21
|
+
self._phases: list[tuple[float, float, float]] = []
|
|
22
|
+
self._rng = random.Random(1)
|
|
23
|
+
self._noise: list[float] = []
|
|
24
|
+
|
|
25
|
+
def resize(self, bars: int) -> None:
|
|
26
|
+
bars = max(0, bars)
|
|
27
|
+
if bars == len(self.levels):
|
|
28
|
+
return
|
|
29
|
+
self.levels = [0.0] * bars
|
|
30
|
+
self.peaks = [0.0] * bars
|
|
31
|
+
self._noise = [0.0] * bars
|
|
32
|
+
self._seed_phases()
|
|
33
|
+
|
|
34
|
+
def retune(self, seed: str, tempo: float | None = None) -> None:
|
|
35
|
+
"""Give a song its own motion."""
|
|
36
|
+
self._rng = random.Random(seed)
|
|
37
|
+
self._tempo = tempo if tempo and 50 <= tempo <= 220 else self._rng.uniform(84, 132)
|
|
38
|
+
self._seed_phases()
|
|
39
|
+
|
|
40
|
+
def _seed_phases(self) -> None:
|
|
41
|
+
rng = self._rng
|
|
42
|
+
self._phases = [
|
|
43
|
+
(rng.uniform(0, math.tau), rng.uniform(0.7, 2.6), rng.uniform(0, math.tau)) for _ in self.levels
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
def step(self, dt: float, playing: bool, volume: float) -> None:
|
|
47
|
+
bars = len(self.levels)
|
|
48
|
+
if not bars:
|
|
49
|
+
self.energy *= 0.8
|
|
50
|
+
return
|
|
51
|
+
self._t += dt
|
|
52
|
+
t = self._t
|
|
53
|
+
beat = (t * self._tempo / 60.0) % 1.0
|
|
54
|
+
kick = math.exp(-5.0 * beat)
|
|
55
|
+
offbeat = math.exp(-7.0 * ((beat + 0.5) % 1.0))
|
|
56
|
+
gain = (0.35 + 0.65 * min(1.0, volume)) if playing else 0.0
|
|
57
|
+
rng = self._rng
|
|
58
|
+
total = 0.0
|
|
59
|
+
for i in range(bars):
|
|
60
|
+
u = i / max(1, bars - 1)
|
|
61
|
+
a, rate, b = self._phases[i]
|
|
62
|
+
self._noise[i] += (rng.random() - self._noise[i]) * min(1.0, dt * 9.0)
|
|
63
|
+
shape = 0.92 - 0.55 * u**0.8
|
|
64
|
+
body = 0.5 + 0.5 * math.sin(t * rate + a) * math.sin(t * 0.37 * rate + b)
|
|
65
|
+
low = kick * max(0.0, 1.0 - u * 3.2)
|
|
66
|
+
mid = offbeat * max(0.0, 1.0 - abs(u - 0.45) * 3.0) * 0.6
|
|
67
|
+
target = gain * min(1.0, shape * (0.26 + 0.62 * body + 0.34 * self._noise[i]) + 0.6 * low + mid * 0.55)
|
|
68
|
+
level = self.levels[i]
|
|
69
|
+
if target > level:
|
|
70
|
+
level += (target - level) * min(1.0, dt * 22.0)
|
|
71
|
+
else:
|
|
72
|
+
level += (target - level) * min(1.0, dt * 6.5)
|
|
73
|
+
self.levels[i] = level
|
|
74
|
+
peak = self.peaks[i] - dt * 0.38
|
|
75
|
+
self.peaks[i] = level if level > peak else max(0.0, peak)
|
|
76
|
+
if u < 0.3:
|
|
77
|
+
total += level
|
|
78
|
+
low_bars = max(1, int(bars * 0.3) + 1)
|
|
79
|
+
self.energy += (min(1.0, total / low_bars * 1.3) - self.energy) * min(1.0, dt * 14.0)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def still(self) -> bool:
|
|
83
|
+
return self.energy < 0.004 and all(p < 0.004 for p in self.peaks)
|
cloudspin/themes.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Colour for everything that isn't the sky.
|
|
2
|
+
|
|
3
|
+
Two themes are alive: ``sky`` takes its colours from whatever the sky is doing,
|
|
4
|
+
and ``cover`` takes them from the playing song's cover art. The rest are fixed
|
|
5
|
+
palettes, and you can add your own under ``[themes.<name>]`` in the config.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .color import RGB, approach, lerp, parse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Theme:
|
|
17
|
+
name: str
|
|
18
|
+
label: str
|
|
19
|
+
accent: RGB | None = None
|
|
20
|
+
accent2: RGB | None = None
|
|
21
|
+
background: RGB | None = None
|
|
22
|
+
disc: RGB | None = None # record label, defaults to the accent
|
|
23
|
+
disc2: RGB | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
BUILT_IN: dict[str, Theme] = {
|
|
27
|
+
theme.name: theme
|
|
28
|
+
for theme in (
|
|
29
|
+
Theme("sky", "Colours follow the sky"),
|
|
30
|
+
Theme("cover", "Colours follow the cover art"),
|
|
31
|
+
Theme("amber", "Amber", (255, 184, 82), (255, 112, 84), (20, 14, 10)),
|
|
32
|
+
Theme("orchid", "Orchid", (214, 150, 255), (255, 120, 190), (17, 11, 24)),
|
|
33
|
+
Theme("lagoon", "Lagoon", (84, 214, 206), (96, 160, 255), (8, 17, 24)),
|
|
34
|
+
Theme("moss", "Moss", (170, 220, 104), (72, 196, 150), (10, 17, 13)),
|
|
35
|
+
Theme("rose", "Rose", (255, 140, 150), (255, 196, 140), (22, 12, 15)),
|
|
36
|
+
Theme("mono", "Mono", (232, 232, 232), (140, 140, 140), (12, 12, 12)),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_themes(custom: dict[str, dict[str, str]]) -> dict[str, Theme]:
|
|
42
|
+
"""Built-in themes plus any valid ones from the config file."""
|
|
43
|
+
themes = dict(BUILT_IN)
|
|
44
|
+
for name, table in custom.items():
|
|
45
|
+
if name in ("sky", "cover"):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
colours = {
|
|
49
|
+
key: parse(table[key])
|
|
50
|
+
for key in ("accent", "accent2", "background", "disc", "disc2")
|
|
51
|
+
if key in table
|
|
52
|
+
}
|
|
53
|
+
except ValueError:
|
|
54
|
+
continue
|
|
55
|
+
if "accent" not in colours:
|
|
56
|
+
continue
|
|
57
|
+
colours.setdefault("accent2", colours["accent"])
|
|
58
|
+
themes[name] = Theme(name=name, label=name.capitalize(), **colours)
|
|
59
|
+
return themes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Palette:
|
|
64
|
+
"""The colours every widget paints with, resolved for this moment."""
|
|
65
|
+
|
|
66
|
+
bg: RGB = (10, 14, 24)
|
|
67
|
+
accent: RGB = (255, 202, 98)
|
|
68
|
+
accent2: RGB = (112, 182, 255)
|
|
69
|
+
disc: RGB = (255, 202, 98)
|
|
70
|
+
disc2: RGB = (112, 182, 255)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def text(self) -> RGB:
|
|
74
|
+
return (238, 241, 246)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def soft(self) -> RGB:
|
|
78
|
+
return lerp(self.bg, self.text, 0.62)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def faint(self) -> RGB:
|
|
82
|
+
return lerp(self.bg, self.text, 0.34)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def raised(self) -> RGB:
|
|
86
|
+
return lerp(self.bg, self.text, 0.085)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def lifted(self) -> RGB:
|
|
90
|
+
return lerp(self.bg, self.text, 0.16)
|
|
91
|
+
|
|
92
|
+
def key(self) -> tuple:
|
|
93
|
+
return (self.bg, self.accent, self.accent2, self.disc, self.disc2)
|
|
94
|
+
|
|
95
|
+
def ease_to(self, target: Palette, k: float) -> None:
|
|
96
|
+
self.bg = approach(self.bg, target.bg, k)
|
|
97
|
+
self.accent = approach(self.accent, target.accent, k)
|
|
98
|
+
self.accent2 = approach(self.accent2, target.accent2, k)
|
|
99
|
+
self.disc = approach(self.disc, target.disc, k)
|
|
100
|
+
self.disc2 = approach(self.disc2, target.disc2, k)
|
cloudspin/vinyl.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The record: grooves that catch the light, a label that turns, a tonearm that tracks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from .canvas import Canvas
|
|
8
|
+
from .color import RGB, lerp
|
|
9
|
+
from .songlook import THUMB
|
|
10
|
+
|
|
11
|
+
RPM = 33.333
|
|
12
|
+
_LIGHT = math.radians(-38.0) # where the sheen sits on the grooves
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Vinyl:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.w = 0
|
|
18
|
+
self.h = 0
|
|
19
|
+
self.angle = 0.0
|
|
20
|
+
self.speed = 0.0 # 0 stopped .. 1 full speed
|
|
21
|
+
self.reach = 1.16 # tonearm head, in disc radii from the centre
|
|
22
|
+
self.cx = 0.0
|
|
23
|
+
self.cy = 0.0
|
|
24
|
+
self.radius = 0.0
|
|
25
|
+
self._groove: list[tuple[int, int, int, float, float, int]] = []
|
|
26
|
+
self._label: list[tuple[int, float, float, float, int]] = []
|
|
27
|
+
self._halo: list[tuple[int, int]] = []
|
|
28
|
+
self._pivot = (0.0, 0.0)
|
|
29
|
+
self._arm = 0.0
|
|
30
|
+
self._base = 0.0
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def width_for(pixel_height: int) -> int:
|
|
34
|
+
"""How many columns a record of this height needs, tonearm included."""
|
|
35
|
+
radius = (pixel_height - 2) / 2.0
|
|
36
|
+
return int(radius * 2.36) + 6
|
|
37
|
+
|
|
38
|
+
def resize(self, w: int, h: int) -> None:
|
|
39
|
+
if (w, h) == (self.w, self.h):
|
|
40
|
+
return
|
|
41
|
+
self.w, self.h = w, h
|
|
42
|
+
radius = max(3.0, min((h - 2) / 2.0, (w - 6) / 2.36))
|
|
43
|
+
self.radius = radius
|
|
44
|
+
self.cx = radius + 2.0
|
|
45
|
+
self.cy = (h - 1) / 2.0
|
|
46
|
+
cx, cy = self.cx, self.cy
|
|
47
|
+
groove, label, halo = [], [], []
|
|
48
|
+
label_edge = 0.36
|
|
49
|
+
gaps = (0.53, 0.69, 0.84)
|
|
50
|
+
for y in range(h):
|
|
51
|
+
for x in range(w):
|
|
52
|
+
dx, dy = x - cx, y - cy
|
|
53
|
+
dist = math.hypot(dx, dy)
|
|
54
|
+
d = dist / radius
|
|
55
|
+
i = y * w + x
|
|
56
|
+
if d > 1.0 + 0.5 / radius:
|
|
57
|
+
fall = 1.0 - (d - 1.0) / 0.55
|
|
58
|
+
if fall > 0.0:
|
|
59
|
+
halo.append((i, int(fall * fall * 256)))
|
|
60
|
+
continue
|
|
61
|
+
theta = math.atan2(dy, dx)
|
|
62
|
+
if d <= label_edge + 0.5 / radius:
|
|
63
|
+
cover = min(1.0, (label_edge - d) * radius + 0.5)
|
|
64
|
+
label.append((i, dx / (label_edge * radius), dy / (label_edge * radius), d, int(cover * 256)))
|
|
65
|
+
if cover >= 1.0:
|
|
66
|
+
continue
|
|
67
|
+
edge = min(1.0, (1.0 - d) * radius + 0.5)
|
|
68
|
+
base = 15.0 + 6.0 * math.sin(dist * 2.3)
|
|
69
|
+
for gap in gaps:
|
|
70
|
+
if abs(d - gap) < 0.55 / radius:
|
|
71
|
+
base -= 7.0
|
|
72
|
+
if d > 0.955:
|
|
73
|
+
base += 16.0
|
|
74
|
+
if d < label_edge + 1.2 / radius:
|
|
75
|
+
base -= 5.0
|
|
76
|
+
wedge = max(0.0, math.cos(2.0 * (theta - _LIGHT))) ** 14
|
|
77
|
+
sheen = wedge * (0.62 + 0.38 * math.sin(math.pi * min(1.0, max(0.0, (d - 0.36) / 0.64))))
|
|
78
|
+
phase = 3.0 * theta + d * 9.0
|
|
79
|
+
groove.append((i, int(base), int(sheen * 256), math.sin(phase), math.cos(phase), int(edge * 256)))
|
|
80
|
+
self._groove, self._label, self._halo = groove, label, halo
|
|
81
|
+
self._pivot = (cx + radius * 1.0, cy - radius * 0.84)
|
|
82
|
+
self._arm = radius * 1.25
|
|
83
|
+
self._base = math.hypot(self._pivot[0] - cx, self._pivot[1] - cy)
|
|
84
|
+
|
|
85
|
+
def step(self, dt: float, spinning: bool, loaded: bool, progress: float) -> None:
|
|
86
|
+
goal = 1.0 if spinning else 0.0
|
|
87
|
+
rate = 0.45 if spinning else 0.9
|
|
88
|
+
self.speed += (goal - self.speed) * (1.0 - math.exp(-dt / rate))
|
|
89
|
+
if abs(goal - self.speed) < 0.004:
|
|
90
|
+
self.speed = goal
|
|
91
|
+
self.angle = (self.angle + self.speed * (RPM / 60.0) * math.tau * dt) % math.tau
|
|
92
|
+
reach = 0.93 - 0.50 * max(0.0, min(1.0, progress)) if loaded else 1.16
|
|
93
|
+
self.reach += (reach - self.reach) * (1.0 - math.exp(-dt / 0.3))
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def moving(self) -> bool:
|
|
97
|
+
return self.speed > 0.0
|
|
98
|
+
|
|
99
|
+
def render(
|
|
100
|
+
self, cv: Canvas, bg: RGB, label: RGB, label2: RGB, glow: RGB, pulse: float, art: bytes | None = None
|
|
101
|
+
) -> None:
|
|
102
|
+
if cv.w != self.w or cv.h != self.h or not self._groove:
|
|
103
|
+
return
|
|
104
|
+
cv.fill(bg)
|
|
105
|
+
R, G, B = cv.r, cv.g, cv.b
|
|
106
|
+
br, bg_, bb = bg
|
|
107
|
+
|
|
108
|
+
strength = 0.07 + 0.2 * pulse
|
|
109
|
+
hr, hg, hb = int(glow[0] * strength), int(glow[1] * strength), int(glow[2] * strength)
|
|
110
|
+
for i, fall in self._halo:
|
|
111
|
+
R[i] = min(255, br + (hr * fall >> 8))
|
|
112
|
+
G[i] = min(255, bg_ + (hg * fall >> 8))
|
|
113
|
+
B[i] = min(255, bb + (hb * fall >> 8))
|
|
114
|
+
|
|
115
|
+
turn = 3.0 * self.angle
|
|
116
|
+
cos_t, sin_t = math.cos(turn), math.sin(turn)
|
|
117
|
+
tr, tg, tb = glow[0] * 0.30, glow[1] * 0.30, glow[2] * 0.30
|
|
118
|
+
for i, base, sheen, s3, c3, edge in self._groove:
|
|
119
|
+
shimmer = s3 * cos_t - c3 * sin_t
|
|
120
|
+
v = base + shimmer * (2.5 + sheen * 0.085) + sheen * 0.26
|
|
121
|
+
r = v + (tr * sheen) / 256.0
|
|
122
|
+
g = v + (tg * sheen) / 256.0
|
|
123
|
+
b = v + 2.0 + (tb * sheen) / 256.0
|
|
124
|
+
if edge < 256:
|
|
125
|
+
r = br + (r - br) * edge / 256.0
|
|
126
|
+
g = bg_ + (g - bg_) * edge / 256.0
|
|
127
|
+
b = bb + (b - bb) * edge / 256.0
|
|
128
|
+
R[i] = 0 if r < 0 else 255 if r > 255 else int(r)
|
|
129
|
+
G[i] = 0 if g < 0 else 255 if g > 255 else int(g)
|
|
130
|
+
B[i] = 0 if b < 0 else 255 if b > 255 else int(b)
|
|
131
|
+
|
|
132
|
+
ca, sa = math.cos(self.angle), math.sin(self.angle)
|
|
133
|
+
hole = max(0.05, 1.0 / self.radius)
|
|
134
|
+
ring = hole * 2.0
|
|
135
|
+
dark = (10, 10, 13)
|
|
136
|
+
metal = (176, 180, 190)
|
|
137
|
+
stripe = lerp(label2, (255, 255, 255), 0.1)
|
|
138
|
+
rim = lerp(label, (0, 0, 0), 0.32)
|
|
139
|
+
half = THUMB / 2.0
|
|
140
|
+
for i, nx, ny, d, cover in self._label:
|
|
141
|
+
u = nx * ca + ny * sa
|
|
142
|
+
v = -nx * sa + ny * ca
|
|
143
|
+
if d < hole:
|
|
144
|
+
tone = dark
|
|
145
|
+
elif d < ring:
|
|
146
|
+
tone = metal
|
|
147
|
+
elif art is not None:
|
|
148
|
+
ax = min(THUMB - 1, max(0, int((u * 0.98 + 1.0) * half)))
|
|
149
|
+
ay = min(THUMB - 1, max(0, int((v * 0.98 + 1.0) * half)))
|
|
150
|
+
k = (ay * THUMB + ax) * 3
|
|
151
|
+
tone = (art[k], art[k + 1], art[k + 2])
|
|
152
|
+
if u * u + v * v > 0.86:
|
|
153
|
+
tone = lerp(tone, (0, 0, 0), 0.35)
|
|
154
|
+
elif u * u + v * v > 0.84:
|
|
155
|
+
tone = rim
|
|
156
|
+
elif abs(v) < 0.24 and abs(u) > 0.3:
|
|
157
|
+
tone = stripe
|
|
158
|
+
else:
|
|
159
|
+
tone = label
|
|
160
|
+
if cover >= 256:
|
|
161
|
+
R[i], G[i], B[i] = tone
|
|
162
|
+
else:
|
|
163
|
+
R[i] += (tone[0] - R[i]) * cover >> 8
|
|
164
|
+
G[i] += (tone[1] - G[i]) * cover >> 8
|
|
165
|
+
B[i] += (tone[2] - B[i]) * cover >> 8
|
|
166
|
+
|
|
167
|
+
self._paint_arm(cv, glow)
|
|
168
|
+
|
|
169
|
+
def _paint_arm(self, cv: Canvas, accent: RGB) -> None:
|
|
170
|
+
px, py = self._pivot
|
|
171
|
+
radius, arm, base = self.radius, self._arm, self._base
|
|
172
|
+
rho = self.reach * radius
|
|
173
|
+
cos_a = max(-1.0, min(1.0, (base * base + arm * arm - rho * rho) / (2.0 * base * arm)))
|
|
174
|
+
heading = math.atan2(self.cy - py, self.cx - px) - math.acos(cos_a)
|
|
175
|
+
ux, uy = math.cos(heading), math.sin(heading)
|
|
176
|
+
hx, hy = px + ux * arm, py + uy * arm
|
|
177
|
+
thick = max(1.0, radius * 0.075)
|
|
178
|
+
cv.line(px - ux * radius * 0.2, py - uy * radius * 0.2, px, py, (112, 116, 128), 256, thick * 2.2)
|
|
179
|
+
cv.line(px, py, hx, hy, (204, 208, 218), 256, thick)
|
|
180
|
+
cv.line(hx - ux * radius * 0.17, hy - uy * radius * 0.17, hx, hy, accent, 256, thick * 2.3)
|
|
181
|
+
cv.disc(px, py, max(1.4, radius * 0.1), (148, 152, 164))
|
|
182
|
+
cv.disc(px, py, max(0.5, radius * 0.04), (232, 234, 240))
|