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, }