Files
RFP_Infinity-Vis/app/patterns/builtin/special.py

329 lines
15 KiB
Python

from __future__ import annotations
import math
import random
from app.core.colors import brighten, sample_palette, smoothstep
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import BasePattern, PatternContext, PatternDescriptor
from .common import (
_random_vivid_color,
_sample_for_cycle,
_sample_for_tile,
_temporal_noise,
_tile_sample,
_with_led_pixels,
)
class StrobePattern(BasePattern):
descriptor = PatternDescriptor(
"strobe",
"Strobe",
"Fast on/off pulses with duty-cycle control.",
(
"brightness",
"fade",
"tempo_multiplier",
"strobe_mode",
"color_mode",
"primary_color",
"secondary_color",
"palette",
"pixel_group_size",
"strobe_duty_cycle",
),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
if context.params.strobe_mode == "random_pixels":
return self._render_random_pixels(context, grouped=True)
if context.params.strobe_mode == "random_leds":
return self._render_random_pixels(context, grouped=False)
phase = context.pattern_tempo_phase * 4.0
bucket = math.floor(phase)
on = phase % 1.0 < context.params.strobe_duty_cycle
primary, _secondary = _sample_for_cycle(context, context.time_s * 0.1, bucket * 17.1 + 3.0)
if on:
return {tile.tile_id: _tile_sample(primary, primary, intensity=1.0) for tile in context.sorted_tiles()}
black = RGBColor.black()
return {tile.tile_id: _tile_sample(black, black, intensity=0.0, boost=0.0) for tile in context.sorted_tiles()}
def _render_random_pixels(self, context: PatternContext, *, grouped: bool) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
bucket = math.floor(context.pattern_tempo_phase * 10.0)
density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98)
pixel_group_size = max(1, min(5, int(round(context.params.pixel_group_size)))) if grouped else 1
for tile in context.sorted_tiles():
amount = (tile.col - 1) / max(1, context.cols - 1)
primary, _secondary = _sample_for_cycle(context, amount, bucket * 29.7 + tile.row * 11.7 + tile.col * 17.9)
led_pixels: dict[str, list[RGBColor]] = {}
lit_leds = 0
total_leds = 0
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
count = max(1, segment.led_count)
for group_start in range(0, count, pixel_group_size):
group_index = group_start // pixel_group_size
group_end = min(count, group_start + pixel_group_size)
seed = bucket * 19.7 + tile.row * 31.3 + tile.col * 17.9 + segment.start_channel * 0.11 + group_index * 1.73
active = _temporal_noise(seed) < density
if context.params.color_mode == "palette":
color = sample_palette(context.params.palette, (amount + group_start / max(1, count - 1) * 0.2) % 1.0)
else:
color = primary
for _index in range(group_start, group_end):
segment_pixels.append(color if active else RGBColor.black())
lit_leds += 1 if active else 0
total_leds += 1
led_pixels[segment.name] = segment_pixels
activity = lit_leds / max(1, total_leds)
preview_level = clamp(activity * 8.0, 0.0, 1.0)
fill = primary.scaled(preview_level) if lit_leds else RGBColor.black()
sample = _tile_sample(fill, primary, intensity=preview_level, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class StopwatchPattern(BasePattern):
descriptor = PatternDescriptor(
"stopwatch",
"Stopwatch",
"LEDs fill from 1 to N and then clear from N back to 1 on every tile.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "stopwatch_mode"),
)
def __init__(self) -> None:
self._last_base_phase_position: float | None = None
def _tile_led_count(self, tile) -> int:
return max(1, sum(segment.led_count for segment in tile.segments))
def _tile_cycle_color(self, context: PatternContext, tile, cycle_index: int) -> RGBColor:
amount = (tile.col - 1 + tile.row - 1) / max(1, context.rows + context.cols - 2)
if context.params.color_mode == "random_colors":
return _random_vivid_color(cycle_index * 53.1 + tile.row * 11.7 + tile.col * 17.9)
primary, _secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
return primary
def _crossed_full_count_peak(self, previous_position: float, current_position: float, cycle_length: int, led_count: int) -> bool:
if current_position <= previous_position or cycle_length <= 0:
return False
next_peak = math.floor(previous_position / cycle_length) * cycle_length + led_count
if next_peak < previous_position:
next_peak += cycle_length
return next_peak <= current_position
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
base_phase_position = context.pattern_tempo_phase * 20.0
previous_base_phase_position = self._last_base_phase_position
use_phase_bridge = (
previous_base_phase_position is not None
and previous_base_phase_position <= base_phase_position
and (base_phase_position - previous_base_phase_position) <= 512.0
)
self._last_base_phase_position = base_phase_position
for tile in context.sorted_tiles():
led_count = self._tile_led_count(tile)
cycle_length = max(1, led_count * 2)
offset = 0
if context.params.stopwatch_mode == "random":
offset = int(_temporal_noise(tile.row * 13.7 + tile.col * 23.9) * cycle_length)
tile_phase_position = base_phase_position + offset
cycle_index = int(tile_phase_position // cycle_length)
phase = tile_phase_position % cycle_length
active_count = phase + 1 if phase < led_count else (2 * led_count) - phase
active_count = max(1, min(led_count, int(round(active_count))))
if use_phase_bridge and previous_base_phase_position is not None:
previous_tile_phase = previous_base_phase_position + offset
if self._crossed_full_count_peak(previous_tile_phase, tile_phase_position, cycle_length, led_count):
active_count = led_count
color = self._tile_cycle_color(context, tile, cycle_index)
remaining = active_count
led_pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for _index in range(segment.led_count):
lit = remaining > 0
segment_pixels.append(color if lit else RGBColor.black())
if lit:
remaining -= 1
led_pixels[segment.name] = segment_pixels
activity = active_count / max(1, led_count)
fill = color.scaled(activity)
sample = _tile_sample(fill, color, intensity=activity, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class SnakePattern(BasePattern):
descriptor = PatternDescriptor(
"snake",
"Snake",
"A random self-playing snake roaming across the wall.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "randomness"),
)
def __init__(self) -> None:
self._rng = random.Random(1337)
self._shape: tuple[int, int] | None = None
self._snake: list[tuple[int, int]] = []
self._direction: tuple[int, int] = (0, 1)
self._apple: tuple[int, int] | None = None
self._blink_until_time: float | None = None
self._target_length = 4
self._last_time_s: float | None = None
self._step_progress = 0.0
def _reset(self, rows: int, cols: int) -> None:
start_row = rows // 2
length = max(2, min(self._target_length, cols))
start_col = min(cols - 1, max(length - 1, cols // 2))
self._snake = [(start_row, start_col - index) for index in range(length)]
self._direction = (0, 1)
self._apple = None
self._spawn_apple(rows, cols)
self._blink_until_time = None
self._shape = (rows, cols)
self._last_time_s = None
self._step_progress = 0.0
def _spawn_apple(self, rows: int, cols: int) -> None:
occupied = set(self._snake)
candidates = [(row, col) for row in range(rows) for col in range(cols) if (row, col) not in occupied]
self._apple = self._rng.choice(candidates) if candidates else None
def _neighbors(self, head: tuple[int, int], rows: int, cols: int) -> list[tuple[int, int]]:
row, col = head
neighbors = []
for d_row, d_col in ((0, 1), (1, 0), (0, -1), (-1, 0)):
next_row = row + d_row
next_col = col + d_col
if 0 <= next_row < rows and 0 <= next_col < cols:
neighbors.append((next_row, next_col))
return neighbors
def _manhattan(self, cell_a: tuple[int, int], cell_b: tuple[int, int]) -> int:
return abs(cell_a[0] - cell_b[0]) + abs(cell_a[1] - cell_b[1])
def _turn_left(self, direction: tuple[int, int]) -> tuple[int, int]:
return (-direction[1], direction[0])
def _turn_right(self, direction: tuple[int, int]) -> tuple[int, int]:
return (direction[1], -direction[0])
def _advance(self, rows: int, cols: int, randomness: float, current_time: float) -> None:
if not self._snake:
self._reset(rows, cols)
head = self._snake[0]
occupied = set(self._snake[:-1])
candidate_directions = [
self._direction,
self._turn_left(self._direction),
self._turn_right(self._direction),
]
candidates: list[tuple[tuple[int, int], tuple[int, int]]] = []
for next_direction in candidate_directions:
next_cell = (head[0] + next_direction[0], head[1] + next_direction[1])
if not (0 <= next_cell[0] < rows and 0 <= next_cell[1] < cols):
continue
if next_cell in occupied:
continue
candidates.append((next_cell, next_direction))
if not candidates:
reverse_direction = (-self._direction[0], -self._direction[1])
reverse_cell = (head[0] + reverse_direction[0], head[1] + reverse_direction[1])
if 0 <= reverse_cell[0] < rows and 0 <= reverse_cell[1] < cols and reverse_cell not in occupied:
candidates.append((reverse_cell, reverse_direction))
if not candidates:
self._reset(rows, cols)
return
def openness(cell: tuple[int, int]) -> int:
blocked = set(self._snake[:-2]) if len(self._snake) > 2 else set()
return sum(1 for neighbor in self._neighbors(cell, rows, cols) if neighbor not in blocked)
turniness = max(0.0, min(1.0, randomness / 1.5))
best_cell, best_direction = candidates[0]
best_score = -10_000.0
for cell, next_direction in candidates:
straight_bonus = 2.4 if next_direction == self._direction else 0.0
turn_penalty = -0.55 if next_direction != self._direction else 0.0
apple_bonus = 0.0
if self._apple is not None:
apple_bonus = max(0.0, (rows + cols) - self._manhattan(cell, self._apple)) * 0.7
if cell == self._apple:
apple_bonus += 5.0
score = openness(cell) + straight_bonus + turn_penalty + apple_bonus + self._rng.random() * (0.08 + turniness * 0.45)
if score > best_score:
best_score = score
best_cell = cell
best_direction = next_direction
self._direction = best_direction
self._snake.insert(0, best_cell)
ate_apple = best_cell == self._apple
if ate_apple:
self._blink_until_time = current_time + 0.12
self._spawn_apple(rows, cols)
while len(self._snake) > self._target_length:
self._snake.pop()
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
rows = max(1, context.rows)
cols = max(1, context.cols)
if self._shape != (rows, cols):
self._reset(rows, cols)
delta_s = 0.0
if self._last_time_s is not None:
raw_delta = context.time_s - self._last_time_s
if 0.0 < raw_delta <= 0.5:
delta_s = raw_delta
self._last_time_s = context.time_s
move_rate = context.pattern_tempo_hz * 2.2
self._step_progress += delta_s * move_rate
steps_to_run = min(3, int(self._step_progress))
if steps_to_run:
self._step_progress -= steps_to_run
for _ in range(steps_to_run):
self._advance(rows, cols, context.params.randomness, context.time_s)
if not self._snake:
self._reset(rows, cols)
self._last_time_s = context.time_s
body_lookup = {cell: index for index, cell in enumerate(self._snake)}
blinking = self._blink_until_time is not None and context.time_s <= self._blink_until_time
result: dict[str, TilePatternSample] = {}
for tile in context.sorted_tiles():
row_index = tile.row - 1
col_index = tile.col - 1
primary, secondary = _sample_for_tile(context, 0.0)
if (row_index, col_index) in body_lookup:
is_head = body_lookup[(row_index, col_index)] == 0
fill = brighten(primary, 0.22) if blinking and is_head else primary
result[tile.tile_id] = _tile_sample(fill, primary, intensity=1.0, boost=0.06 if blinking and is_head else 0.03)
elif self._apple == (row_index, col_index):
fill = secondary
accent = brighten(secondary, 0.15)
result[tile.tile_id] = _tile_sample(fill, accent, intensity=0.92, boost=0.03)
else:
fill = RGBColor.black()
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.0, boost=0.0)
return result