1 Commits

Author SHA1 Message Date
94266d5a7c feat(patterns): reverse animation direction via preset n8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:01 +12:00
11 changed files with 131 additions and 62 deletions

View File

@@ -26,13 +26,13 @@ class Aurora:
c = self.driver.apply_brightness(colors[idx], preset.b)
w = 255 - abs(128 - ((i * 8 + phase) & 255)) * 2
w = max(0, min(255, w + shimmer))
self.driver.n[i] = (
self.driver.n[self.driver.led_i(preset, i)] = (
(c[0] * w) // 255,
(c[1] * w) // 255,
(c[2] * w) // 255,
)
self.driver.n.write()
phase = (phase + 1) & 255
phase = (phase + self.driver.signed(preset, 1)) & 255
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
@@ -76,9 +76,9 @@ class Aurora:
peak = lerp3(colors[fi], colors[fi + 1], frac)
peak = self.driver.apply_brightness(peak, preset.b)
mixf = min(255, int(w * contrast * 2) >> 1)
self.driver.n[i] = lerp3(bg, peak, mixf)
self.driver.n[self.driver.led_i(preset, i)] = lerp3(bg, peak, mixf)
self.driver.n.write()
phase = (phase + drift) % 256
phase = (phase + self.driver.signed(preset, drift)) % 256
last = utime.ticks_add(last, d)
if not preset.a:
yield

View File

@@ -46,12 +46,14 @@ class Blizzard:
for pos, ci, wj in flakes:
p = pos
lateral = wind + (wj if wj else 0)
p -= speed
p += lateral
p -= self.driver.signed(preset, speed)
p += self.driver.signed(preset, lateral)
if p < -2 or p >= nled + 2:
continue
pi = max(0, min(nled - 1, int(p)))
self.driver.n[pi] = self.driver.apply_brightness(colors[ci], preset.b)
self.driver.n[self.driver.led_i(preset, pi)] = self.driver.apply_brightness(
colors[ci], preset.b
)
nf.append([p, ci, wj])
flakes = nf

View File

@@ -12,7 +12,7 @@ class Chase:
def _run_marquee(self, preset, colors):
on_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
off_len = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1)
step = max(1, abs(self.driver.signed(preset, int(preset.n3) if int(preset.n3) > 0 else 1)))
phase = self.driver.step % (on_len + off_len)
last = utime.ticks_ms()
while True:
@@ -23,9 +23,9 @@ class Chase:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
m = (i + phase) % (on_len + off_len)
self.driver.n[i] = c if m < on_len else bg_color
self.driver.n[self.driver.led_i(preset, i)] = c if m < on_len else bg_color
self.driver.n.write()
phase = (phase + step) % (on_len + off_len)
phase = (phase + self.driver.signed(preset, step)) % (on_len + off_len)
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
@@ -66,8 +66,8 @@ class Chase:
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
n3 = self.driver.signed(preset, int(preset.n3)) # Step movement on even steps
n4 = self.driver.signed(preset, int(preset.n4)) # Step movement on odd steps
segment_length = n1 + n2
@@ -101,9 +101,9 @@ class Chase:
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
self.driver.n[self.driver.led_i(preset, i)] = color0
else:
self.driver.n[i] = color1
self.driver.n[self.driver.led_i(preset, i)] = color1
self.driver.n.write()
print("[chase] step", step_count)
@@ -147,9 +147,9 @@ class Chase:
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
self.driver.n[self.driver.led_i(preset, i)] = color0
else:
self.driver.n[i] = color1
self.driver.n[self.driver.led_i(preset, i)] = color1
self.driver.n.write()
print("[chase] step", step_count)

View File

@@ -18,7 +18,7 @@ class ColourCycle:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def _render_gradient(self, colors, phase, brightness):
def _render_gradient(self, preset, colors, phase, brightness):
num_leds = self.driver.num_leds
color_count = len(colors)
if num_leds <= 0 or color_count <= 0:
@@ -40,14 +40,16 @@ class ColourCycle:
c1[1] + ((c2[1] - c1[1]) * frac) // 256,
c1[2] + ((c2[2] - c1[2]) * frac) // 256,
)
self.driver.n[i] = self.driver.apply_brightness(blended, brightness)
self.driver.n[self.driver.led_i(preset, i)] = self.driver.apply_brightness(
blended, brightness
)
self.driver.n.write()
def _render_rainbow(self, phase, brightness):
def _render_rainbow(self, preset, phase, brightness):
num_leds = self.driver.num_leds
for i in range(num_leds):
rc_index = (i * 256 // max(1, num_leds)) + phase
self.driver.n[i] = self.driver.apply_brightness(
self.driver.n[self.driver.led_i(preset, i)] = self.driver.apply_brightness(
self._wheel(rc_index & 255), brightness
)
self.driver.n.write()
@@ -64,8 +66,8 @@ class ColourCycle:
if mode == 1:
if not preset.a:
self._render_rainbow(phase, preset.b)
self.driver.step = (phase + step_amount) % 256
self._render_rainbow(preset, phase, preset.b)
self.driver.step = (phase + self.driver.signed(preset, step_amount)) % 256
yield
return
last_update = utime.ticks_ms()
@@ -73,16 +75,16 @@ class ColourCycle:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
self._render_rainbow(phase, preset.b)
phase = (phase + step_amount) % 256
self._render_rainbow(preset, phase, preset.b)
phase = (phase + self.driver.signed(preset, step_amount)) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield
colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)]
if not preset.a:
self._render_gradient(colors, phase, preset.b)
self.driver.step = (phase + step_amount) % 256
self._render_gradient(preset, colors, phase, preset.b)
self.driver.step = (phase + self.driver.signed(preset, step_amount)) % 256
yield
return
@@ -91,8 +93,8 @@ class ColourCycle:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
self._render_gradient(colors, phase, preset.b)
phase = (phase + step_amount) % 256
self._render_gradient(preset, colors, phase, preset.b)
phase = (phase + self.driver.signed(preset, step_amount)) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield

View File

@@ -44,7 +44,7 @@ class Icicles:
if idx >= nled:
break
br = ((k + 1) * 255) // max(1, ic_len)
self.driver.n[idx] = (
self.driver.n[self.driver.led_i(preset, idx)] = (
(tip[0] * br + bg[0] * (255 - br)) // 255,
(tip[1] * br + bg[1] * (255 - br)) // 255,
(tip[2] * br + bg[2] * (255 - br)) // 255,
@@ -52,7 +52,7 @@ class Icicles:
aidx += 1
self.driver.n.write()
phase = (phase + phase_step) % span
phase = (phase + self.driver.signed(preset, phase_step)) % span
last = utime.ticks_add(last, d_ms)
if not preset.a:

View File

@@ -30,9 +30,9 @@ class Meteor:
base = colors[color_index % len(colors)]
lit = self.driver.apply_brightness(base, preset.b)
if 0 <= head < self.driver.num_leds:
self.driver.n[head] = lit
self.driver.n[self.driver.led_i(preset, head)] = lit
self.driver.n.write()
head += direction * speed
head += self.driver.signed(preset, direction * speed)
if head >= self.driver.num_leds + tail_len:
head = self.driver.num_leds - 1
direction = -1
@@ -62,14 +62,22 @@ class Meteor:
i1 = p1 - t
if 0 <= i1 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[i1] = ((c1[0] * s) // 255, (c1[1] * s) // 255, (c1[2] * s) // 255)
self.driver.n[self.driver.led_i(preset, i1)] = (
(c1[0] * s) // 255,
(c1[1] * s) // 255,
(c1[2] * s) // 255,
)
i2 = p2 + t
if 0 <= i2 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[i2] = ((c2[0] * s) // 255, (c2[1] * s) // 255, (c2[2] * s) // 255)
self.driver.n[self.driver.led_i(preset, i2)] = (
(c2[0] * s) // 255,
(c2[1] * s) // 255,
(c2[2] * s) // 255,
)
self.driver.n.write()
p1 += speed
p2 -= speed
p1 += self.driver.signed(preset, speed)
p2 -= self.driver.signed(preset, speed)
if p1 - tail > self.driver.num_leds and p2 + tail < 0:
p1 = 0
p2 = self.driver.num_leds - 1 - gap
@@ -89,10 +97,10 @@ class Meteor:
if dist < 0:
dist = -dist
if dist > width:
self.driver.n[i] = bg_color
self.driver.n[self.driver.led_i(preset, i)] = bg_color
else:
scale = ((width - dist) * 255) // max(1, width)
self.driver.n[i] = (
self.driver.n[self.driver.led_i(preset, i)] = (
(base[0] * scale) // 255,
(base[1] * scale) // 255,
(base[2] * scale) // 255,
@@ -101,7 +109,7 @@ class Meteor:
if pause_frames > 0:
pause_frames -= 1
else:
center += direction
center += self.driver.signed(preset, direction)
if center >= self.driver.num_leds - 1:
center = self.driver.num_leds - 1
direction = -1
@@ -121,7 +129,11 @@ class Meteor:
if mode == 1:
gap = max(0, int(preset.n3))
p1, p2 = 0, self.driver.num_leds - 1 - gap
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
p1, p2 = nled - 1, gap
else:
p1, p2 = 0, nled - 1 - gap
last = utime.ticks_ms()
while True:
p1, p2, last, stepped = self._run_comet_dual(preset, colors, p1, p2, last)
@@ -131,7 +143,11 @@ class Meteor:
yield
if mode == 2:
color_index, center, direction, pause_frames = 0, 0, 1, 0
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
color_index, center, direction, pause_frames = 0, max(0, nled - 1), -1, 0
else:
color_index, center, direction, pause_frames = 0, 0, 1, 0
last_update = utime.ticks_ms()
while True:
color_index, center, direction, pause_frames, last_update, stepped = (
@@ -144,7 +160,11 @@ class Meteor:
return
yield
color_index, head, direction = 0, 0, 1
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
color_index, head, direction = 0, max(0, nled - 1), -1
else:
color_index, head, direction = 0, 0, 1
last_update = utime.ticks_ms()
while True:
color_index, head, direction, last_update, stepped = self._run_meteor(

View File

@@ -12,7 +12,7 @@ class Particles:
def _run_snowfall(self, preset, colors, flakes, last):
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 20)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
speed = max(1, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 1)))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
@@ -25,8 +25,10 @@ class Particles:
nf = []
for pos, ci in flakes:
if 0 <= pos < self.driver.num_leds:
self.driver.n[pos] = self.driver.apply_brightness(colors[ci], preset.b)
pos -= speed
self.driver.n[self.driver.led_i(preset, pos)] = self.driver.apply_brightness(
colors[ci], preset.b
)
pos -= self.driver.signed(preset, speed)
if pos >= -1:
nf.append([pos, ci])
self.driver.n.write()
@@ -34,7 +36,7 @@ class Particles:
def _run_starfall(self, preset, colors, stars, last):
rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 14))
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
speed = max(1, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 2)))
tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10)
max_stars = 4
d = max(1, int(preset.d))
@@ -65,13 +67,14 @@ class Particles:
(base[2] * fade) // 255,
)
lit = self.driver.apply_brightness(lit, preset.b)
o = self.driver.n[idx]
self.driver.n[idx] = (
pix = self.driver.led_i(preset, idx)
o = self.driver.n[pix]
self.driver.n[pix] = (
max(o[0], lit[0]),
max(o[1], lit[1]),
max(o[2], lit[2]),
)
h -= speed
h -= self.driver.signed(preset, speed)
if h >= -tail:
s["h"] = h
ns.append(s)

View File

@@ -0,0 +1,19 @@
"""Strip install direction: n5 bit 0 reverses along-strip motion (upside-down wiring)."""
def is_reversed(preset):
return bool(int(getattr(preset, "n5", 0) or 0) & 1)
def led_i(driver, preset, logical_index):
"""Map a logical strip index (0 = pattern start) to a physical pixel index."""
n = int(driver.num_leds)
i = int(logical_index)
if 0 <= i < n and is_reversed(preset):
return n - 1 - i
return i
def signed(preset, value):
v = int(value)
return -v if is_reversed(preset) else v

View File

@@ -13,13 +13,8 @@ class Pulse:
bg_base = preset.background_or(colors)
self.driver.fill(self.driver.apply_brightness(bg_base, preset.b))
manual = not preset.a
color_index = self.driver.step % max(1, len(colors))
if not preset.a:
# Manual / beat trigger: each select restarts this generator and resets
# cycle_start below. Advancing step here makes each beat the next colour
# without requiring a full wall-clock cycle between beats.
nclr = max(1, len(colors))
self.driver.step = (color_index + 1) % nclr
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
@@ -29,7 +24,7 @@ class Pulse:
attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms
decay_ms = max(0, int(preset.n3)) # Decay time in ms
delay_ms = max(0, int(preset.d))
delay_ms = 0 if manual else max(0, int(preset.d))
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
if total_ms <= 0:
@@ -58,12 +53,13 @@ class Pulse:
# Delay phase: LEDs off between pulses
self.driver.fill(bg_color)
else:
# End of cycle: auto advances colour and loops; manual already
# advanced step at run start for the next beat.
if not preset.a:
break
color_index = (color_index + 1) % max(1, len(colors))
# End of cycle: advance colour for the next run, then loop or stop.
nclr = max(1, len(colors))
color_index = (color_index + 1) % nclr
self.driver.step = color_index
if manual:
self.driver.fill(bg_color)
break
cycle_start = now
yield
continue

View File

@@ -32,6 +32,18 @@ class Preset:
int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"}
allowed_fields = {"p", "c", "d", "b", "a", "bg", "n1", "n2", "n3", "n4", "n5", "n6"}
for key, value in data.items():
if key == "reverse":
try:
if isinstance(value, bool):
self.n5 = 1 if value else 0
elif isinstance(value, (int, float)):
self.n5 = 1 if int(value) else 0
elif isinstance(value, str):
lowered = value.lower()
self.n5 = 1 if lowered in ("true", "1", "yes", "on") else 0
except (TypeError, ValueError):
pass
continue
key = aliases.get(key, key)
if key not in allowed_fields:
continue

View File

@@ -198,7 +198,7 @@ class Presets:
if (
preset_name == self.selected
and not preset.a
and preset.p == "chase"
and preset.p in ("chase", "pulse")
and self.generator is not None
):
while self.generator is not None:
@@ -222,6 +222,21 @@ class Presets:
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
def is_reversed(self, preset):
from patterns.pattern_direction import is_reversed as _is_reversed
return _is_reversed(preset)
def led_i(self, preset, logical_index):
from patterns.pattern_direction import led_i as _led_i
return _led_i(self, preset, logical_index)
def signed(self, preset, value):
from patterns.pattern_direction import signed as _signed
return _signed(preset, value)
def apply_brightness(self, color, brightness_override=None):
# Combine per-preset brightness (override) with global brightness self.b
local = brightness_override if brightness_override is not None else 255