258 lines
11 KiB
Python
258 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
import random
|
|
from dataclasses import dataclass
|
|
|
|
from .colors import mix, palette_color, rgb_from_hex, scale
|
|
from .models import MappingSpec, PatternParams, RGB
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TilePoint:
|
|
row: int
|
|
col: int
|
|
u: float
|
|
v: float
|
|
index: int
|
|
|
|
@property
|
|
def tile_key(self) -> str:
|
|
return f"{self.row}:{self.col}"
|
|
|
|
|
|
def pattern_ids() -> list[str]:
|
|
return sorted(PATTERN_REGISTRY)
|
|
|
|
|
|
def render_pattern(mapping: MappingSpec, pattern_id: str, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
points = _tile_points(mapping)
|
|
renderer = PATTERN_REGISTRY.get(pattern_id, _render_solid)
|
|
return renderer(points, mapping, params, time_s, tempo_bpm)
|
|
|
|
|
|
def utility_pattern(mapping: MappingSpec, utility_mode: str, selected_tile_id: str | None, time_s: float) -> list[RGB]:
|
|
points = _tile_points(mapping)
|
|
ordered = mapping.ordered_tiles()
|
|
if utility_mode == "blackout":
|
|
return [(0, 0, 0) for _ in points]
|
|
if utility_mode == "identify":
|
|
flash = _wave(time_s * 8.0)
|
|
color = (255, 255, 255) if flash > 0.5 else (0, 0, 0)
|
|
return [color for _ in points]
|
|
if utility_mode == "single_tile":
|
|
return [(255, 255, 255) if ordered[index].tile_id == selected_tile_id else (0, 0, 0) for index, _point in enumerate(points)]
|
|
return [(0, 0, 0) for _ in points]
|
|
|
|
|
|
def _tile_points(mapping: MappingSpec) -> list[TilePoint]:
|
|
rows = max(1, mapping.rows - 1)
|
|
cols = max(1, mapping.cols - 1)
|
|
points: list[TilePoint] = []
|
|
for index, tile in enumerate(mapping.ordered_tiles()):
|
|
points.append(
|
|
TilePoint(
|
|
row=tile.row,
|
|
col=tile.col,
|
|
u=(tile.col - 1) / cols,
|
|
v=(tile.row - 1) / rows,
|
|
index=index,
|
|
)
|
|
)
|
|
return points
|
|
|
|
|
|
def _primary_secondary(params: PatternParams, phase: float = 0.5) -> tuple[RGB, RGB]:
|
|
if params.color_mode == "palette":
|
|
return palette_color(params.palette, phase), palette_color(params.palette, (phase + 0.33) % 1.0)
|
|
return rgb_from_hex(params.primary_color), rgb_from_hex(params.secondary_color)
|
|
|
|
|
|
def _wave(value: float) -> float:
|
|
return 0.5 + 0.5 * math.sin(value)
|
|
|
|
|
|
def _axis_projection(point: TilePoint, angle_deg: float) -> float:
|
|
angle = math.radians(angle_deg % 360.0)
|
|
x = point.u - 0.5
|
|
y = point.v - 0.5
|
|
return (x * math.cos(angle)) + (y * math.sin(angle))
|
|
|
|
|
|
def _direction_progress(point: TilePoint, direction: str) -> float:
|
|
if direction == "right_to_left":
|
|
return 1.0 - point.u
|
|
if direction == "bottom_to_top":
|
|
return 1.0 - point.v
|
|
if direction == "top_to_bottom":
|
|
return point.v
|
|
return point.u
|
|
|
|
|
|
def _render_solid(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
primary, _secondary = _primary_secondary(params, 0.55)
|
|
return [scale(primary, params.brightness) for _ in points]
|
|
|
|
|
|
def _render_checker(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
return [scale(a if (point.row + point.col) % 2 == 0 else b, params.brightness) for point in points]
|
|
|
|
|
|
def _render_row_gradient(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
return [scale(mix(a, b, point.v), params.brightness) for point in points]
|
|
|
|
|
|
def _render_column_gradient(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
return [scale(mix(a, b, point.u), params.brightness) for point in points]
|
|
|
|
|
|
def _render_breathing(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, _b = _primary_secondary(params, 0.4)
|
|
pulse = 0.15 + 0.85 * _wave(time_s * (tempo_bpm / 60.0) * params.tempo_multiplier)
|
|
return [scale(a, params.brightness * pulse) for _ in points]
|
|
|
|
|
|
def _render_center_pulse(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
pulse = _wave(time_s * (tempo_bpm / 60.0) * params.tempo_multiplier)
|
|
colors: list[RGB] = []
|
|
for point in points:
|
|
dx = point.u - 0.5
|
|
dy = point.v - 0.5
|
|
dist = math.sqrt(dx * dx + dy * dy) * 1.5
|
|
strength = max(0.0, 1.0 - abs(dist - pulse))
|
|
colors.append(scale(mix(b, a, strength), params.brightness))
|
|
return colors
|
|
|
|
|
|
def _render_sparkle(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
rng = random.Random(int(time_s * 120.0))
|
|
return [scale(a if rng.random() < (0.15 + params.randomness * 0.35) else b, params.brightness) for _ in points]
|
|
|
|
|
|
def _render_wave_line(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
phase = time_s * (tempo_bpm / 60.0) * params.tempo_multiplier
|
|
colors: list[RGB] = []
|
|
for point in points:
|
|
value = _wave((point.u * math.pi * 4.0) + phase * math.pi * 2.0 + point.v * 1.2)
|
|
colors.append(scale(mix(b, a, value), params.brightness))
|
|
return colors
|
|
|
|
|
|
def _render_scan(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
position = ((time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0) * 2.0 - 1.0
|
|
band = max(0.08, min(1.2, params.on_width * 0.35))
|
|
colors: list[RGB] = []
|
|
for point in points:
|
|
projection = _axis_projection(point, params.angle)
|
|
active = abs(projection - position) <= band
|
|
colors.append(scale(a if active else b, params.brightness))
|
|
return colors
|
|
|
|
|
|
def _render_arrow(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
head = (time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0
|
|
colors: list[RGB] = []
|
|
for point in points:
|
|
progress = _direction_progress(point, params.direction)
|
|
width = 0.15 + 0.25 * (1.0 - abs(point.v - 0.5) * 2.0)
|
|
colors.append(scale(a if abs(progress - head) < width else b, params.brightness))
|
|
return colors
|
|
|
|
|
|
def _render_scan_dual(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
progress = (time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0
|
|
colors: list[RGB] = []
|
|
for point in points:
|
|
axis = _direction_progress(point, params.direction)
|
|
active = abs(axis - progress) < 0.2 or abs(axis - (1.0 - progress)) < 0.2
|
|
colors.append(scale(a if active else b, params.brightness))
|
|
return colors
|
|
|
|
|
|
def _render_sweep(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
progress = (time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0
|
|
return [scale(a if _direction_progress(point, params.direction) <= progress else b, params.brightness) for point in points]
|
|
|
|
|
|
def _render_saw(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
shift = (time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0
|
|
return [scale(mix(b, a, (_direction_progress(point, params.direction) + shift) % 1.0), params.brightness) for point in points]
|
|
|
|
|
|
def _render_two_dots(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
p1 = (time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0
|
|
p2 = (p1 + 0.5) % 1.0
|
|
colors: list[RGB] = []
|
|
for point in points:
|
|
axis = _direction_progress(point, params.direction)
|
|
active = abs(axis - p1) < 0.12 or abs(axis - p2) < 0.12
|
|
colors.append(scale(a if active else b, params.brightness))
|
|
return colors
|
|
|
|
|
|
def _render_strobe(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
phase = (time_s * (tempo_bpm / 60.0) * params.tempo_multiplier) % 1.0
|
|
on = phase < max(0.02, min(0.98, params.strobe_duty_cycle))
|
|
if params.strobe_mode == "global":
|
|
return [scale(a if on else b, params.brightness) for _ in points]
|
|
|
|
rng = random.Random(int(time_s * 80.0))
|
|
result: list[RGB] = []
|
|
for point in points:
|
|
local_on = on
|
|
if params.strobe_mode in {"random_pixels", "random_leds"}:
|
|
local_on = rng.random() > 0.5
|
|
elif params.strobe_mode == "checker":
|
|
local_on = on and ((point.row + point.col) % 2 == 0)
|
|
result.append(scale(a if local_on else b, params.brightness))
|
|
return result
|
|
|
|
|
|
def _render_stopwatch(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
highlight = int(time_s) % max(1, len(points))
|
|
return [scale(a if point.index == highlight else b, params.brightness) for point in points]
|
|
|
|
|
|
def _render_snake(points: list[TilePoint], mapping: MappingSpec, params: PatternParams, time_s: float, tempo_bpm: float) -> list[RGB]:
|
|
a, b = _primary_secondary(params)
|
|
order = sorted(points, key=lambda point: (point.row, point.col if point.row % 2 else -point.col))
|
|
head = int(time_s * (tempo_bpm / 60.0) * params.tempo_multiplier * max(1.0, params.step_size)) % len(order)
|
|
tail = max(1, int(params.block_size))
|
|
active = {order[(head - offset) % len(order)].tile_key for offset in range(tail)}
|
|
return [scale(a if point.tile_key in active else b, params.brightness) for point in points]
|
|
|
|
|
|
PATTERN_REGISTRY = {
|
|
"arrow": _render_arrow,
|
|
"breathing": _render_breathing,
|
|
"center_pulse": _render_center_pulse,
|
|
"checker": _render_checker,
|
|
"column_gradient": _render_column_gradient,
|
|
"row_gradient": _render_row_gradient,
|
|
"saw": _render_saw,
|
|
"scan": _render_scan,
|
|
"scan_dual": _render_scan_dual,
|
|
"snake": _render_snake,
|
|
"solid": _render_solid,
|
|
"sparkle": _render_sparkle,
|
|
"stopwatch": _render_stopwatch,
|
|
"strobe": _render_strobe,
|
|
"sweep": _render_sweep,
|
|
"two_dots": _render_two_dots,
|
|
"wave_line": _render_wave_line,
|
|
}
|