scrollkit 0.8.3__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.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Swarm-reveal animation — a flock of "birds" (or bees) assembles the target image.
|
|
3
|
+
|
|
4
|
+
A small flock flies in with classic boids flocking (separation / alignment /
|
|
5
|
+
cohesion); as birds pass over target pixels they "capture" them — the captured
|
|
6
|
+
pixels light up permanently and accumulate into the text/logo. Once every target
|
|
7
|
+
pixel is captured the birds disperse, leaving the assembled image.
|
|
8
|
+
|
|
9
|
+
This is a device-feasible reimagining of the original 150-200 bird boids demo
|
|
10
|
+
(which only ran via precomputed paths). The cost killers there were three
|
|
11
|
+
separate O(n^2) neighbor loops and an O(birds x pixels) target scan *every frame*.
|
|
12
|
+
Here:
|
|
13
|
+
|
|
14
|
+
* a **small flock** (the plan's own observation: fewer birds flock more visibly),
|
|
15
|
+
* **one combined neighbor pass** computing all three rules together with squared
|
|
16
|
+
distances (no per-pair sqrt for the radius gates),
|
|
17
|
+
* **O(1) opportunistic capture** — each bird only checks the few integer cells
|
|
18
|
+
around itself against the remaining-pixel set, never the whole target list,
|
|
19
|
+
* **O(1) steer-target assignment** — birds pull a target from a shuffled queue,
|
|
20
|
+
|
|
21
|
+
so per-frame work is dominated by the ~n^2 neighbor pass at a small n. Measured
|
|
22
|
+
on a MatrixPortal S3 (incl. the panel refresh): 14 birds ~25 ms/frame (safe),
|
|
23
|
+
20 ~34 ms, 28 ~48 ms — so the default ``num_birds=14`` keeps headroom under the
|
|
24
|
+
50 ms / 20 fps budget while the desktop simulator can use more for a denser flock.
|
|
25
|
+
|
|
26
|
+
Two transparent overlay layers via ``display.gfx`` (sim + device identical): the
|
|
27
|
+
captured-text layer is written incrementally (never fully redrawn); the birds
|
|
28
|
+
layer is cleared and redrawn each frame (a handful of pixels).
|
|
29
|
+
|
|
30
|
+
Frame-driven :class:`SwarmReveal` (call :meth:`step` once per frame) plus a
|
|
31
|
+
blocking :func:`show_swarm_splash` convenience wrapper, mirroring
|
|
32
|
+
:class:`scrollkit.effects.DripReveal`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import math
|
|
37
|
+
import random
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Boids tuning (radii stored squared to avoid sqrt in the gates).
|
|
41
|
+
_SEP_R2 = 5.0 * 5.0
|
|
42
|
+
_ALI_R2 = 15.0 * 15.0
|
|
43
|
+
_COH_R2 = 20.0 * 20.0
|
|
44
|
+
_SEP_W = 0.40
|
|
45
|
+
_ALI_W = 0.30
|
|
46
|
+
_COH_W = 0.20
|
|
47
|
+
_COH_GAIN = 0.02 # cohesion pulls gently toward the local center
|
|
48
|
+
_MAX_NUDGE = 0.10 # random per-frame jitter (organic wander)
|
|
49
|
+
_CAPTURE_DIST = 0.9 # a bird captures its target once this close to it
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ['SwarmReveal', 'show_swarm_splash']
|
|
53
|
+
|
|
54
|
+
class _Bird:
|
|
55
|
+
def __init__(self, x, y, vx, vy):
|
|
56
|
+
self.x = x
|
|
57
|
+
self.y = y
|
|
58
|
+
self.vx = vx
|
|
59
|
+
self.vy = vy
|
|
60
|
+
self.phase = random.uniform(0, 6.2832) # wing-flap phase offset
|
|
61
|
+
self.spd = random.uniform(0.7, 1.3) # individual speed variation
|
|
62
|
+
self.target = None # (x, y) it steers toward
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SwarmReveal:
|
|
66
|
+
"""Frame-driven swarm that assembles ``pixels`` then disperses.
|
|
67
|
+
|
|
68
|
+
Build it with the target ``pixels`` (e.g. from
|
|
69
|
+
:func:`pixels_from_text` / :func:`pixels_from_font_text`), :meth:`start` it
|
|
70
|
+
with the display, then call :meth:`step` once per frame until it reports
|
|
71
|
+
complete. The captured-text overlay persists until :meth:`detach`.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
pixels: Iterable of ``(x, y)`` target cells to assemble.
|
|
75
|
+
text_color: 24-bit RGB of the captured/assembled image. Used as the
|
|
76
|
+
single flat color when ``text_colors`` is ``None``.
|
|
77
|
+
text_colors: Optional sequence of 24-bit ``0xRRGGBB`` colors forming a
|
|
78
|
+
ramp (low->high) for the assembled image — e.g. a range
|
|
79
|
+
of yellows->oranges. When ``None`` (the default) the
|
|
80
|
+
image is the single ``text_color`` (a 2-entry palette,
|
|
81
|
+
byte-for-byte the original behavior). When given, the
|
|
82
|
+
text layer uses a ``len(text_colors) + 1`` palette and
|
|
83
|
+
each target pixel's ramp stop is precomputed once in
|
|
84
|
+
:meth:`start` (no per-frame / per-capture float math), so
|
|
85
|
+
the measured ``num_birds**2`` frame budget is unaffected.
|
|
86
|
+
color_axis: How the ramp maps across the image when ``text_colors``
|
|
87
|
+
is set: ``"vertical"`` (default — top of the glyph is
|
|
88
|
+
``text_colors[0]``, bottom is ``text_colors[-1]``),
|
|
89
|
+
``"horizontal"`` (left->right), or ``"diagonal"``
|
|
90
|
+
(top-left->bottom-right). The ramp spans exactly the
|
|
91
|
+
bounding box of the target pixels, not the whole panel.
|
|
92
|
+
bird_color: 24-bit RGB of the flock.
|
|
93
|
+
num_birds: Flock size. **This is the hardware-feasibility knob.**
|
|
94
|
+
The per-frame cost grows ~``num_birds**2`` (the boids
|
|
95
|
+
neighbor pass). MEASURED per-frame time on a MatrixPortal
|
|
96
|
+
S3 (bit_depth 4, incl. the panel refresh):
|
|
97
|
+
|
|
98
|
+
14 birds -> ~25 ms (the default; safe headroom)
|
|
99
|
+
20 birds -> ~34 ms (usable, near the ceiling)
|
|
100
|
+
28 birds -> ~48 ms (at the 50 ms / 20 fps limit)
|
|
101
|
+
40 birds -> ~95 ms (too slow: ~10 fps)
|
|
102
|
+
100 birds -> ~0.6 s (unusable on-device)
|
|
103
|
+
|
|
104
|
+
**On-device keep num_birds <= ~20.** The desktop
|
|
105
|
+
simulator has no such limit — use more there for a denser
|
|
106
|
+
flock. Fewer birds also flock more visibly.
|
|
107
|
+
bird_speed: Pixels per frame a bird may travel (a higher value fills
|
|
108
|
+
faster but flocks less tightly).
|
|
109
|
+
disperse_frames: Frames the flock flies off after the image is complete.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, pixels, text_color=0xFFCC00, bird_color=0xFFE08A,
|
|
113
|
+
num_birds=14, bird_speed=2.4, disperse_frames=18,
|
|
114
|
+
text_colors=None, color_axis="vertical"):
|
|
115
|
+
self.pixels = pixels
|
|
116
|
+
self.text_color = text_color
|
|
117
|
+
# A ramp of >=1 colors (low->high). Empty/None -> single-color path.
|
|
118
|
+
self.text_colors = tuple(text_colors) if text_colors else None
|
|
119
|
+
self.color_axis = color_axis if color_axis in (
|
|
120
|
+
"vertical", "horizontal", "diagonal") else "vertical"
|
|
121
|
+
self.bird_color = bird_color
|
|
122
|
+
self.num_birds = num_birds if num_birds > 1 else 1
|
|
123
|
+
self.bird_speed = bird_speed if bird_speed > 0.5 else 0.5
|
|
124
|
+
self.disperse_frames = disperse_frames if disperse_frames > 0 else 0
|
|
125
|
+
|
|
126
|
+
self._display = None
|
|
127
|
+
self._gfx = None
|
|
128
|
+
self._w = 0
|
|
129
|
+
self._h = 0
|
|
130
|
+
self._text_bmp = None
|
|
131
|
+
self._text_tile = None
|
|
132
|
+
self._index_map = None # {(x, y): palette_index} ramp lookup (gradient only)
|
|
133
|
+
self._birds_bmp = None
|
|
134
|
+
self._birds_tile = None
|
|
135
|
+
|
|
136
|
+
self._remaining = set() # uncaptured target cells
|
|
137
|
+
self._total = 0
|
|
138
|
+
self._queue = [] # shuffled targets for O(1) steer assignment
|
|
139
|
+
self._qi = 0
|
|
140
|
+
self._birds = []
|
|
141
|
+
self._t = 0.0 # animation clock (frames * dt)
|
|
142
|
+
self._disperse_left = -1 # >=0 once all captured (counts down)
|
|
143
|
+
self._complete = False
|
|
144
|
+
|
|
145
|
+
# --- lifecycle ------------------------------------------------------------
|
|
146
|
+
def start(self, display):
|
|
147
|
+
gfx = display.gfx
|
|
148
|
+
self._display = display
|
|
149
|
+
self._gfx = gfx
|
|
150
|
+
w, h = display.width, display.height
|
|
151
|
+
self._w, self._h = w, h
|
|
152
|
+
|
|
153
|
+
# Captured-text layer (written incrementally; never fully redrawn).
|
|
154
|
+
# Single-color path: a 2-entry palette (index 1 = text_color). Gradient
|
|
155
|
+
# path: a (len(text_colors) + 1)-entry palette holding the ramp, with the
|
|
156
|
+
# per-pixel index precomputed below once _remaining is known.
|
|
157
|
+
self._index_map = None
|
|
158
|
+
if self.text_colors is None:
|
|
159
|
+
self._text_bmp = gfx.Bitmap(w, h, 2)
|
|
160
|
+
tpal = gfx.Palette(2)
|
|
161
|
+
tpal.make_transparent(0)
|
|
162
|
+
tpal[1] = self.text_color
|
|
163
|
+
else:
|
|
164
|
+
n = len(self.text_colors)
|
|
165
|
+
self._text_bmp = gfx.Bitmap(w, h, n + 1)
|
|
166
|
+
tpal = gfx.Palette(n + 1)
|
|
167
|
+
if hasattr(tpal, "make_transparent"):
|
|
168
|
+
tpal.make_transparent(0)
|
|
169
|
+
for i, c in enumerate(self.text_colors):
|
|
170
|
+
tpal[i + 1] = c
|
|
171
|
+
self._text_tile = gfx.TileGrid(self._text_bmp, pixel_shader=tpal)
|
|
172
|
+
|
|
173
|
+
# Birds layer (cleared + redrawn each frame). Added AFTER text so birds
|
|
174
|
+
# fly in front; both have a transparent background.
|
|
175
|
+
self._birds_bmp = gfx.Bitmap(w, h, 2)
|
|
176
|
+
bpal = gfx.Palette(2)
|
|
177
|
+
bpal.make_transparent(0)
|
|
178
|
+
bpal[1] = self.bird_color
|
|
179
|
+
self._birds_tile = gfx.TileGrid(self._birds_bmp, pixel_shader=bpal)
|
|
180
|
+
|
|
181
|
+
display.add_layer(self._text_tile)
|
|
182
|
+
display.add_layer(self._birds_tile)
|
|
183
|
+
|
|
184
|
+
# Targets in bounds; shuffled queue for steer-target assignment.
|
|
185
|
+
self._remaining = set((x, y) for (x, y) in self.pixels
|
|
186
|
+
if 0 <= x < w and 0 <= y < h)
|
|
187
|
+
self._total = len(self._remaining)
|
|
188
|
+
self._queue = list(self._remaining)
|
|
189
|
+
_shuffle(self._queue)
|
|
190
|
+
self._qi = 0
|
|
191
|
+
|
|
192
|
+
# Gradient path: precompute each in-bounds target cell's palette index
|
|
193
|
+
# ONCE here so the per-capture write in step() is a single O(1) dict
|
|
194
|
+
# lookup with no float math (keeps the num_birds^2 frame budget intact).
|
|
195
|
+
if self.text_colors is not None:
|
|
196
|
+
self._index_map = self._build_index_map(self._remaining)
|
|
197
|
+
|
|
198
|
+
# Spawn the flock from the screen edges in small clusters.
|
|
199
|
+
self._birds = [self._spawn_bird() for _ in range(self.num_birds)]
|
|
200
|
+
for b in self._birds:
|
|
201
|
+
b.target = self._next_target()
|
|
202
|
+
|
|
203
|
+
self._t = 0.0
|
|
204
|
+
self._disperse_left = -1
|
|
205
|
+
self._complete = False
|
|
206
|
+
|
|
207
|
+
def detach(self):
|
|
208
|
+
"""Remove both overlay layers (no-op if already detached)."""
|
|
209
|
+
if self._display is None:
|
|
210
|
+
return
|
|
211
|
+
if self._text_tile is not None:
|
|
212
|
+
self._display.remove_layer(self._text_tile)
|
|
213
|
+
self._text_tile = None
|
|
214
|
+
if self._birds_tile is not None:
|
|
215
|
+
self._display.remove_layer(self._birds_tile)
|
|
216
|
+
self._birds_tile = None
|
|
217
|
+
|
|
218
|
+
# --- helpers --------------------------------------------------------------
|
|
219
|
+
def _spawn_bird(self):
|
|
220
|
+
w, h = self._w, self._h
|
|
221
|
+
edge = random.randint(0, 3)
|
|
222
|
+
s = self.bird_speed
|
|
223
|
+
if edge == 0: # left
|
|
224
|
+
return _Bird(-1.0, random.uniform(0, h), s, random.uniform(-0.3, 0.3))
|
|
225
|
+
if edge == 1: # right
|
|
226
|
+
return _Bird(w + 1.0, random.uniform(0, h), -s, random.uniform(-0.3, 0.3))
|
|
227
|
+
if edge == 2: # top
|
|
228
|
+
return _Bird(random.uniform(0, w), -1.0, random.uniform(-0.3, 0.3), s)
|
|
229
|
+
return _Bird(random.uniform(0, w), h + 1.0, random.uniform(-0.3, 0.3), -s) # bottom
|
|
230
|
+
|
|
231
|
+
def _next_target(self):
|
|
232
|
+
"""Pop the next still-uncaptured target from the shuffled queue (O(1) amortized)."""
|
|
233
|
+
q = self._queue
|
|
234
|
+
n = len(q)
|
|
235
|
+
while self._qi < n:
|
|
236
|
+
p = q[self._qi]
|
|
237
|
+
self._qi += 1
|
|
238
|
+
if p in self._remaining:
|
|
239
|
+
return p
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
def _build_index_map(self, cells):
|
|
243
|
+
"""Precompute ``{(x, y): palette_index}`` mapping the ramp over ``cells``.
|
|
244
|
+
|
|
245
|
+
The ramp (palette indices ``1..len(text_colors)``) spans exactly the
|
|
246
|
+
bounding box of the target ``cells`` along ``color_axis`` — so the full
|
|
247
|
+
ramp covers the glyph extent, not the whole panel. Done once in
|
|
248
|
+
:meth:`start`; the float math here never runs per frame or per capture.
|
|
249
|
+
"""
|
|
250
|
+
colors = self.text_colors
|
|
251
|
+
last = len(colors) - 1 # ramp index range 0..last
|
|
252
|
+
if not cells:
|
|
253
|
+
return {}
|
|
254
|
+
xs = [x for (x, y) in cells]
|
|
255
|
+
ys = [y for (x, y) in cells]
|
|
256
|
+
min_x, max_x = min(xs), max(xs)
|
|
257
|
+
min_y, max_y = min(ys), max(ys)
|
|
258
|
+
span_x = max_x - min_x
|
|
259
|
+
span_y = max_y - min_y
|
|
260
|
+
axis = self.color_axis
|
|
261
|
+
index_map = {}
|
|
262
|
+
for (x, y) in cells:
|
|
263
|
+
if axis == "horizontal":
|
|
264
|
+
num, den = (x - min_x), span_x
|
|
265
|
+
elif axis == "diagonal":
|
|
266
|
+
num, den = (x - min_x) + (y - min_y), span_x + span_y
|
|
267
|
+
else: # vertical (default)
|
|
268
|
+
num, den = (y - min_y), span_y
|
|
269
|
+
if den <= 0 or last == 0:
|
|
270
|
+
ramp_i = 0
|
|
271
|
+
else:
|
|
272
|
+
# Nearest ramp stop; +0.5 rounds to nearest (num, last, den >= 0).
|
|
273
|
+
ramp_i = int(num * last / den + 0.5)
|
|
274
|
+
if ramp_i > last:
|
|
275
|
+
ramp_i = last
|
|
276
|
+
index_map[(x, y)] = ramp_i + 1 # palette indices start at 1
|
|
277
|
+
return index_map
|
|
278
|
+
|
|
279
|
+
def _flock(self, b):
|
|
280
|
+
"""All three boids rules in one neighbor pass (squared-distance gates)."""
|
|
281
|
+
sx = sy = ax = ay = cx = cy = 0.0
|
|
282
|
+
sc = ac = cc = 0
|
|
283
|
+
bx, by = b.x, b.y
|
|
284
|
+
for o in self._birds:
|
|
285
|
+
if o is b:
|
|
286
|
+
continue
|
|
287
|
+
dx = bx - o.x
|
|
288
|
+
dy = by - o.y
|
|
289
|
+
d2 = dx * dx + dy * dy
|
|
290
|
+
if d2 >= _COH_R2 or d2 == 0.0:
|
|
291
|
+
continue
|
|
292
|
+
cx += o.x
|
|
293
|
+
cy += o.y
|
|
294
|
+
cc += 1
|
|
295
|
+
if d2 < _ALI_R2:
|
|
296
|
+
ax += o.vx
|
|
297
|
+
ay += o.vy
|
|
298
|
+
ac += 1
|
|
299
|
+
if d2 < _SEP_R2:
|
|
300
|
+
inv = 1.0 / d2 # steer away, stronger when closer
|
|
301
|
+
sx += dx * inv
|
|
302
|
+
sy += dy * inv
|
|
303
|
+
sc += 1
|
|
304
|
+
fx = fy = 0.0
|
|
305
|
+
if sc:
|
|
306
|
+
fx += (sx / sc) * _SEP_W
|
|
307
|
+
fy += (sy / sc) * _SEP_W
|
|
308
|
+
if ac:
|
|
309
|
+
fx += ((ax / ac) - b.vx) * _ALI_W
|
|
310
|
+
fy += ((ay / ac) - b.vy) * _ALI_W
|
|
311
|
+
if cc:
|
|
312
|
+
fx += ((cx / cc) - bx) * _COH_GAIN * _COH_W
|
|
313
|
+
fy += ((cy / cc) - by) * _COH_GAIN * _COH_W
|
|
314
|
+
return fx, fy
|
|
315
|
+
|
|
316
|
+
# --- per-frame ------------------------------------------------------------
|
|
317
|
+
@property
|
|
318
|
+
def is_complete(self):
|
|
319
|
+
return self._complete
|
|
320
|
+
|
|
321
|
+
def step(self):
|
|
322
|
+
"""Advance one frame; render into the overlays. Returns True when done."""
|
|
323
|
+
if self._complete:
|
|
324
|
+
return True
|
|
325
|
+
self._t += 0.05
|
|
326
|
+
w, h = self._w, self._h
|
|
327
|
+
speed = self.bird_speed
|
|
328
|
+
dispersing = self._disperse_left >= 0
|
|
329
|
+
captured_ratio = 0.0
|
|
330
|
+
if self._total:
|
|
331
|
+
captured_ratio = (self._total - len(self._remaining)) / self._total
|
|
332
|
+
|
|
333
|
+
for b in self._birds:
|
|
334
|
+
fx, fy = self._flock(b)
|
|
335
|
+
|
|
336
|
+
if dispersing:
|
|
337
|
+
# Head for the nearest edge so the flock clears off-screen.
|
|
338
|
+
ex = -4.0 if b.x < w * 0.5 else w + 4.0
|
|
339
|
+
ey = -4.0 if b.y < h * 0.5 else h + 4.0
|
|
340
|
+
b.vx += fx + (ex - b.x) * 0.04
|
|
341
|
+
b.vy += fy + (ey - b.y) * 0.04
|
|
342
|
+
else:
|
|
343
|
+
if b.target is None or b.target not in self._remaining:
|
|
344
|
+
b.target = self._next_target()
|
|
345
|
+
if b.target is not None:
|
|
346
|
+
tx, ty = b.target
|
|
347
|
+
dx = tx - b.x
|
|
348
|
+
dy = ty - b.y
|
|
349
|
+
dist = math.sqrt(dx * dx + dy * dy)
|
|
350
|
+
if dist < _CAPTURE_DIST:
|
|
351
|
+
# Arrived: this bird DELIVERS its one pixel. (Deliberate
|
|
352
|
+
# capture — birds don't blanket-paint, so the flock stays
|
|
353
|
+
# the show.) Light it permanently; resume flocking.
|
|
354
|
+
self._remaining.discard(b.target)
|
|
355
|
+
# Single-color: write 1 (unchanged). Gradient: a single
|
|
356
|
+
# precomputed O(1) lookup — no per-capture float math.
|
|
357
|
+
if self._index_map is None:
|
|
358
|
+
self._text_bmp[tx, ty] = 1
|
|
359
|
+
else:
|
|
360
|
+
self._text_bmp[tx, ty] = self._index_map.get(b.target, 1)
|
|
361
|
+
b.target = None
|
|
362
|
+
b.vx += fx
|
|
363
|
+
b.vy += fy
|
|
364
|
+
elif dist < 1.5:
|
|
365
|
+
# Precision: ignore flocking, settle onto the pixel.
|
|
366
|
+
b.vx = dx * 0.3
|
|
367
|
+
b.vy = dy * 0.3
|
|
368
|
+
elif dist < 3.0:
|
|
369
|
+
nvx = (dx / dist) * 0.6
|
|
370
|
+
nvy = (dy / dist) * 0.6
|
|
371
|
+
b.vx = nvx + fx * 0.3
|
|
372
|
+
b.vy = nvy + fy * 0.3
|
|
373
|
+
else:
|
|
374
|
+
# Flock, with attraction to the target growing as the
|
|
375
|
+
# image fills (weak early -> stronger late).
|
|
376
|
+
aw = 0.1 + captured_ratio * 0.5
|
|
377
|
+
b.vx += fx + (dx / dist) * speed * aw
|
|
378
|
+
b.vy += fy + (dy / dist) * speed * aw
|
|
379
|
+
else:
|
|
380
|
+
b.vx += fx
|
|
381
|
+
b.vy += fy
|
|
382
|
+
|
|
383
|
+
# Organic wing-flap + a little randomness.
|
|
384
|
+
b.vx += 0.15 * math.sin(b.phase + self._t * 10.0) * b.spd
|
|
385
|
+
b.vy += 0.10 * math.cos(b.phase + self._t * 8.0) * b.spd
|
|
386
|
+
b.vx += random.uniform(-_MAX_NUDGE, _MAX_NUDGE)
|
|
387
|
+
b.vy += random.uniform(-_MAX_NUDGE, _MAX_NUDGE)
|
|
388
|
+
|
|
389
|
+
# Clamp speed (and keep a floor so birds never stall).
|
|
390
|
+
sp = math.sqrt(b.vx * b.vx + b.vy * b.vy)
|
|
391
|
+
if sp > speed:
|
|
392
|
+
b.vx = (b.vx / sp) * speed
|
|
393
|
+
b.vy = (b.vy / sp) * speed
|
|
394
|
+
elif 0.0 < sp < 0.3:
|
|
395
|
+
b.vx = (b.vx / sp) * 0.3
|
|
396
|
+
b.vy = (b.vy / sp) * 0.3
|
|
397
|
+
|
|
398
|
+
b.x += b.vx
|
|
399
|
+
b.y += b.vy
|
|
400
|
+
|
|
401
|
+
# Wrap around the edges so birds stay in play while capturing.
|
|
402
|
+
if not dispersing:
|
|
403
|
+
if b.x < -2:
|
|
404
|
+
b.x = w + 2
|
|
405
|
+
elif b.x > w + 2:
|
|
406
|
+
b.x = -2
|
|
407
|
+
if b.y < -2:
|
|
408
|
+
b.y = h + 2
|
|
409
|
+
elif b.y > h + 2:
|
|
410
|
+
b.y = -2
|
|
411
|
+
|
|
412
|
+
# Begin dispersing once every pixel is captured.
|
|
413
|
+
if self._disperse_left < 0 and not self._remaining:
|
|
414
|
+
self._disperse_left = self.disperse_frames
|
|
415
|
+
|
|
416
|
+
# Render the birds layer: clear, then one pixel per bird (skip when done).
|
|
417
|
+
bmp = self._birds_bmp
|
|
418
|
+
try:
|
|
419
|
+
bmp.fill(0)
|
|
420
|
+
except (AttributeError, TypeError):
|
|
421
|
+
for xx in range(w):
|
|
422
|
+
for yy in range(h):
|
|
423
|
+
bmp[xx, yy] = 0
|
|
424
|
+
if self._disperse_left != 0:
|
|
425
|
+
for b in self._birds:
|
|
426
|
+
ix = int(b.x + 0.5)
|
|
427
|
+
iy = int(b.y + 0.5)
|
|
428
|
+
if 0 <= ix < w and 0 <= iy < h:
|
|
429
|
+
bmp[ix, iy] = 1
|
|
430
|
+
|
|
431
|
+
if dispersing:
|
|
432
|
+
self._disperse_left -= 1
|
|
433
|
+
if self._disperse_left <= 0:
|
|
434
|
+
self._complete = True
|
|
435
|
+
return self._complete
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _shuffle(lst):
|
|
439
|
+
"""In-place Fisher-Yates (random.shuffle is absent on CircuitPython)."""
|
|
440
|
+
for i in range(len(lst) - 1, 0, -1):
|
|
441
|
+
j = random.randint(0, i)
|
|
442
|
+
lst[i], lst[j] = lst[j], lst[i]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def show_swarm_splash(
|
|
446
|
+
display,
|
|
447
|
+
pixels,
|
|
448
|
+
text_color=0xFFCC00,
|
|
449
|
+
bird_color=0xFFE08A,
|
|
450
|
+
num_birds=14,
|
|
451
|
+
bird_speed=2.4,
|
|
452
|
+
hold_seconds=2.0,
|
|
453
|
+
max_steps=2000,
|
|
454
|
+
text_colors=None,
|
|
455
|
+
color_axis="vertical",
|
|
456
|
+
):
|
|
457
|
+
"""Play a swarm-assembles-the-image animation (blocking convenience wrapper).
|
|
458
|
+
|
|
459
|
+
A flock flies in, captures the ``pixels`` into the text layer, then disperses;
|
|
460
|
+
the assembled image holds for ``hold_seconds`` before the overlays are removed.
|
|
461
|
+
|
|
462
|
+
Args mirror :class:`SwarmReveal`. ``max_steps`` bounds the run so an
|
|
463
|
+
unreachable pixel can never hang the loop.
|
|
464
|
+
|
|
465
|
+
Hardware note: cost grows ~``num_birds**2``. On a MatrixPortal S3 keep
|
|
466
|
+
``num_birds <= ~20`` (14 ~25 ms/frame, 20 ~34 ms, 28 ~48 ms at the 20 fps
|
|
467
|
+
limit, 100 ~0.6 s/frame = unusable). See :class:`SwarmReveal` for the full
|
|
468
|
+
measured table. The desktop simulator has no such limit.
|
|
469
|
+
|
|
470
|
+
Returns ``True`` when finished normally, ``False`` if the display reported a
|
|
471
|
+
close (simulator window closed). On hardware ``show()`` never closes, so this
|
|
472
|
+
is always ``True`` there.
|
|
473
|
+
"""
|
|
474
|
+
swarm = SwarmReveal(pixels, text_color=text_color, bird_color=bird_color,
|
|
475
|
+
num_birds=num_birds, bird_speed=bird_speed,
|
|
476
|
+
text_colors=text_colors, color_axis=color_axis)
|
|
477
|
+
swarm.start(display)
|
|
478
|
+
steps = 0
|
|
479
|
+
while not swarm.is_complete and steps < max_steps:
|
|
480
|
+
swarm.step()
|
|
481
|
+
steps += 1
|
|
482
|
+
if await display.show() is False:
|
|
483
|
+
swarm.detach()
|
|
484
|
+
return False
|
|
485
|
+
await asyncio.sleep(0.05)
|
|
486
|
+
|
|
487
|
+
if await display.show() is False:
|
|
488
|
+
swarm.detach()
|
|
489
|
+
return False
|
|
490
|
+
await asyncio.sleep(hold_seconds)
|
|
491
|
+
swarm.detach()
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# --- advertised feasibility metadata (US7 / FR-026) -------------------------
|
|
496
|
+
# Per frame the cost is the ~num_birds^2 combined neighbor pass (one pass, squared
|
|
497
|
+
# distances) plus O(1) capture/steer per bird and a birds-layer redraw of
|
|
498
|
+
# num_birds pixels. No per-frame heap allocation (flock list + target queue built
|
|
499
|
+
# once in start()). MEASURED on a MatrixPortal S3 (bit_depth 4), per-frame work
|
|
500
|
+
# incl. refresh: 14 birds ~25 ms avg / 37 ms max (safe); 20 ~34/50 (at the
|
|
501
|
+
# ceiling); 28 ~48/68 (over). Default num_birds=14 keeps headroom under the
|
|
502
|
+
# 50 ms / 20 fps budget; the desktop simulator can use more for a denser flock.
|
|
503
|
+
_FEASIBILITY = {
|
|
504
|
+
"hardware_safe": True,
|
|
505
|
+
"allocates_per_frame": False,
|
|
506
|
+
"max_pixel_writes_per_frame": 24, # ~num_birds + a few captures per frame
|
|
507
|
+
"modeled_frame_ms": 25.0, # measured: 14 birds avg on S3 (incl. refresh)
|
|
508
|
+
"note": "cost ~ num_birds^2; measured 14->25ms, 20->34ms, 28->48ms on S3",
|
|
509
|
+
}
|
|
510
|
+
# CLASS only — CircuitPython can't set attributes on function objects, so the
|
|
511
|
+
# wrapper does NOT carry FEASIBILITY (read SwarmReveal.FEASIBILITY instead).
|
|
512
|
+
SwarmReveal.FEASIBILITY = _FEASIBILITY
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Back-compat shim — the implementation now lives in ``display.text_pixels``.
|
|
3
|
+
|
|
4
|
+
``pixels_from_font_text`` / ``font_text_width`` (and the private ``_glyph_fields``
|
|
5
|
+
/ ``_MISSING_ADVANCE``) were relocated to ``scrollkit.display.text_pixels`` so the
|
|
6
|
+
gradient text-fill renderer in ``display/`` can share the one glyph→pixel
|
|
7
|
+
function without ``display`` importing ``effects`` (which would form a cycle and
|
|
8
|
+
pull the RAM-heavy particle/splash modules in just to render text).
|
|
9
|
+
|
|
10
|
+
Import from either this module or the real implementation directly:
|
|
11
|
+
``from scrollkit.effects.text_render import pixels_from_font_text`` and
|
|
12
|
+
``from scrollkit.display.text_pixels import pixels_from_font_text`` both
|
|
13
|
+
resolve to the same function. (``scrollkit.effects`` itself is deliberately
|
|
14
|
+
import-free — see its ``__init__.py`` — so the package-level
|
|
15
|
+
``from scrollkit.effects import pixels_from_font_text`` no longer works.)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ..display.text_pixels import ( # noqa: F401 (re-export)
|
|
19
|
+
_MISSING_ADVANCE,
|
|
20
|
+
_glyph_fields,
|
|
21
|
+
font_text_width,
|
|
22
|
+
pixels_from_font_text,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ["pixels_from_font_text", "font_text_width"]
|