···11+# Overview
22+13Renders a rotating subjective Necker cube and dynamically updates a BlueSky avatar based on the current time.
44+55+# Usage
66+77+```uv run avatar_cube.py```
2839Samples:
410
+25-7
avatar_cube.py
···11from __future__ import annotations
22from dotenv import load_dotenv
33-import os, io, math, datetime as dt
33+import os, io, math, datetime as dt, colorsys
44import numpy as np
55import matplotlib.pyplot as plt
66from matplotlib.patches import Circle
···3636CUBE_RADIUS_PX = math.sqrt(3) * (ZOOM / 2)
3737HALF_FRAME = CUBE_RADIUS_PX + CIRCLE_R + 5
38383939-def render_frame(roll_deg: float, pitch_deg: float, yaw_deg: float) -> bytes:
3939+def render_frame(roll_deg: float, pitch_deg: float, yaw_deg: float, fg_color, bg_color) -> bytes:
4040 """Return PNG bytes of cube at given pitch (deg)."""
4141 # Matplotlib figure — square, no border
4242- fig = plt.figure(figsize=(2, 2), dpi=256, facecolor="white")
4242+ fig = plt.figure(figsize=(2, 2), dpi=256, facecolor=bg_color)
4343 ax = fig.add_axes([0, 0, 1, 1]) # full‑bleed axes
4444+ ax.set_facecolor(bg_color)
4445 ax.set_aspect('equal', adjustable='box')
4546 ax.axis('off')
4647···50515152 # draw circles
5253 for x, y in verts2d:
5353- ax.add_patch(Circle((x, y), CIRCLE_R, color='black', zorder=1))
5454+ ax.add_patch(Circle((x, y), CIRCLE_R, color=fg_color, zorder=1))
54555556 # draw full edges (round caps)
5657 for i, j in EDGES:
5758 (x1, y1), (x2, y2) = verts2d[[i, j]]
5859 ax.plot([x1, x2], [y1, y2], lw=LINE_W,
5959- color='white', solid_capstyle='round', zorder=2)
6060+ color=bg_color, solid_capstyle='round', zorder=2)
60616162 # fixed limits to keep size constant
6263 ax.set_xlim(-HALF_FRAME, HALF_FRAME)
···7172def update_bluesky_avatar(now_utc: dt.datetime | None = None, dry_run=False):
7273 handle = os.getenv('BLUESKY_HANDLE')
7374 app_pw = os.getenv('BLUESKY_APP_PASSWORD')
7474- if not handle or not app_pw:
7575+ if not dry_run and (not handle or not app_pw):
7576 raise RuntimeError('BLUESKY_HANDLE and BLUESKY_APP_PASSWORD must be set')
76777778 now = now_utc or dt.datetime.now(dt.timezone.utc)
···8081 deg_roll = (steps * 7) % 360 # wrap at 360
8182 deg_pitch = (steps * 3) % 360
8283 deg_yaw = (steps * 5) % 360
8383- png = render_frame(deg_roll, deg_pitch, deg_yaw)
8484+8585+ # color calculations
8686+ osc = math.sin(2 * math.pi * steps / 48)
8787+ fg_h = (steps * 13) % 360
8888+ fg_l = 0.25 * (1 + osc) / 2
8989+ fg_s = 0.90
9090+ bg_h = (fg_h + 180) % 360
9191+ bg_l = 0.93 + 0.05 * osc
9292+ bg_s = 0.30
9393+9494+ def hsl_to_rgb(h_deg, s, l):
9595+ r, g, b = colorsys.hls_to_rgb(h_deg / 360, l, s)
9696+ return (r, g, b)
9797+9898+ fg_color = hsl_to_rgb(fg_h, fg_s, fg_l)
9999+ bg_color = hsl_to_rgb(bg_h, bg_s, bg_l)
100100+101101+ png = render_frame(deg_roll, deg_pitch, deg_yaw, fg_color, bg_color)
8410285103 if dry_run:
86104 with open(f'cube-{steps:03d}.png', 'wb') as f: