feat(03-02): implement animation library — 7 types with registry and factory
- AnimationBase ABC with render(t, led_count) and from_params() contract - SolidColorAnimation: time-independent uniform fill - ChaseAnimation: moving lit segment with forward/reverse support - PulseAnimation: sine-wave breathing effect (speed maps to period) - RainbowAnimation: HSV hue cycle across strip via colorsys.hsv_to_rgb - StrobeAnimation: on/off flashing at 1-20 Hz with configurable duty cycle - ColorWipeAnimation: progressive fill from start or end - FireAnimation: Fire2012 algorithm with cooling/sparking/heat-to-color mapping - ANIMATION_REGISTRY + create_animation() factory function - All 24 tests passing
This commit is contained in:
80
lightsync/animations/fire.py
Normal file
80
lightsync/animations/fire.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Fire animation — Fire2012 algorithm port from FastLED."""
|
||||
import random
|
||||
from lightsync.animations.base import AnimationBase
|
||||
|
||||
|
||||
def _fire_step(
|
||||
heat: list[int],
|
||||
led_count: int,
|
||||
cooling: int,
|
||||
sparking: int,
|
||||
) -> None:
|
||||
"""Run one Fire2012 step, mutating heat array in-place.
|
||||
|
||||
Index 0 = bottom of strip (heat source), index N-1 = top.
|
||||
"""
|
||||
# Step 1: Cool every cell
|
||||
for i in range(led_count):
|
||||
cooldown = random.randint(0, (cooling * 10 // led_count) + 2)
|
||||
heat[i] = max(0, heat[i] - cooldown)
|
||||
|
||||
# Step 2: Heat drifts upward (toward index N-1)
|
||||
for i in range(led_count - 1, 1, -1):
|
||||
heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) // 3
|
||||
|
||||
# Step 3: Randomly ignite new sparks near bottom
|
||||
if random.randint(0, 255) < sparking:
|
||||
y = random.randint(0, min(6, led_count - 1))
|
||||
heat[y] = min(255, heat[y] + random.randint(160, 255))
|
||||
|
||||
|
||||
def _heat_to_rgb(h: int) -> tuple[int, int, int]:
|
||||
"""Map heat value 0-255 to black -> red -> yellow -> white palette."""
|
||||
h = max(0, min(255, h))
|
||||
if h < 85:
|
||||
return (h * 3, 0, 0)
|
||||
elif h < 170:
|
||||
h2 = h - 85
|
||||
return (255, h2 * 3, 0)
|
||||
else:
|
||||
h2 = h - 170
|
||||
return (255, 255, min(255, h2 * 3))
|
||||
|
||||
|
||||
class FireAnimation(AnimationBase):
|
||||
"""Fire2012 algorithm — heat diffusion with cooling, sparking, and color mapping.
|
||||
|
||||
Index 0 = bottom of strip (heat source). Flames rise toward index N-1.
|
||||
State is per-instance (heat array) — each instance maintains its own fire.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cooling: int = 55,
|
||||
sparking: int = 120,
|
||||
speed: float = 0.5,
|
||||
) -> None:
|
||||
self.cooling = cooling
|
||||
self.sparking = sparking
|
||||
self.speed = speed
|
||||
self._heat: list[int] = [] # lazy-init per led_count
|
||||
|
||||
def render(self, t: float, led_count: int) -> list[tuple]:
|
||||
# Lazy-init or resize heat array
|
||||
if len(self._heat) != led_count:
|
||||
self._heat = [0] * led_count
|
||||
|
||||
# Speed multiplier: higher speed -> more simulation steps per render
|
||||
steps = max(1, int(self.speed * 3))
|
||||
for _ in range(steps):
|
||||
_fire_step(self._heat, led_count, self.cooling, self.sparking)
|
||||
|
||||
return [_heat_to_rgb(h) for h in self._heat]
|
||||
|
||||
@classmethod
|
||||
def from_params(cls, params: dict) -> "FireAnimation":
|
||||
return cls(
|
||||
cooling=params.get("cooling", 55),
|
||||
sparking=params.get("sparking", 120),
|
||||
speed=params.get("speed", 0.5),
|
||||
)
|
||||
Reference in New Issue
Block a user