from __future__ import annotations import math from app.core.colors import oscillate, smoothstep from app.core.types import RGBColor, TilePatternSample, clamp from ..base import BasePattern, PatternContext, PatternDescriptor from .common import ( _blend_colors, _diagonal_split_sample, _directional_amount, _mirror_position, _sample_for_tile, _scan_band_amount, _scan_projection, _scan_vector, _tile_sample, ) class WaveLinePattern(BasePattern): descriptor = PatternDescriptor( "wave_line", "Wave Line", "A discrete wave line that can travel left, right, up, or down.", ("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette"), temporal_profile="direct", ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} rows = max(1, context.rows) cols = max(1, context.cols) triangle_wave = [0, 1, 2, 1] step = int(math.floor(context.pattern_tempo_phase)) row_scale = max(0, rows - 1) / 2.0 if context.params.direction in {"left_to_right", "right_to_left"}: phase = step if context.params.direction == "left_to_right" else -step active_coords = { (int(round(triangle_wave[(col - phase) % len(triangle_wave)] * row_scale)), col) for col in range(cols) } else: col_scale = max(0, cols - 1) / 2.0 phase = step if context.params.direction == "top_to_bottom" else -step active_coords = { (row, int(round(triangle_wave[(row - phase) % len(triangle_wave)] * col_scale))) for row in range(rows) } for tile in context.sorted_tiles(): row_index = tile.row - 1 col_index = tile.col - 1 amount = col_index / max(1, cols - 1) primary, secondary = _sample_for_tile(context, amount, row_index, col_index) active = (row_index, col_index) in active_coords fill = primary if active else RGBColor.black() result[tile.tile_id] = _tile_sample(fill, primary if active else RGBColor.black(), intensity=1.0 if active else 0.0, boost=0.08 if active else 0.0) return result class ScanPattern(BasePattern): descriptor = PatternDescriptor( "scan", "Scan", "Unified scan renderer for row, column, and diagonal motion.", ( "brightness", "fade", "tempo_multiplier", "scan_style", "angle", "on_width", "off_width", "color_mode", "primary_color", "secondary_color", "palette", ), temporal_profile="direct", ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} tiles = context.sorted_tiles() angle = int(context.params.angle) % 360 vector = _scan_vector(angle) diagonal = abs(vector[0]) == 1 and abs(vector[1]) == 1 orientation = "backslash" if vector[0] * vector[1] < 0 else "slash" if diagonal and orientation == "backslash": split_points = {"a": (2.0 / 3.0, 1.0 / 3.0), "b": (1.0 / 3.0, 2.0 / 3.0)} else: split_points = {"a": (1.0 / 3.0, 1.0 / 3.0), "b": (2.0 / 3.0, 2.0 / 3.0)} lane_points = (split_points["a"], split_points["b"]) if diagonal else ((0.5, 0.5),) lane_progress_values = [ _scan_projection(context, tile.row - 1, tile.col - 1, local_x, local_y, vector) for tile in tiles for local_x, local_y in lane_points ] min_progress = min(lane_progress_values, default=0.0) max_progress = max(lane_progress_values, default=0.0) phase_scale = max(0.25, min(context.params.on_width, 1.0)) phase = context.pattern_tempo_phase * phase_scale for tile in tiles: row_index = tile.row - 1 col_index = tile.col - 1 center_progress = _scan_projection(context, row_index, col_index, 0.5, 0.5, vector) amount = 0.0 if max_progress == min_progress else (center_progress - min_progress) / (max_progress - min_progress) primary, secondary = _sample_for_tile(context, amount, row_index, col_index) off_color = RGBColor.black() if diagonal: amount_a = _scan_band_amount( _scan_projection(context, row_index, col_index, split_points["a"][0], split_points["a"][1], vector), phase, min_progress, max_progress, context.params.on_width, context.params.off_width, context.params.scan_style, ) amount_b = _scan_band_amount( _scan_projection(context, row_index, col_index, split_points["b"][0], split_points["b"][1], vector), phase, min_progress, max_progress, context.params.on_width, context.params.off_width, context.params.scan_style, ) color_a = off_color.mix(primary, amount_a) color_b = off_color.mix(primary, amount_b) result[tile.tile_id] = _diagonal_split_sample( color_a, color_b, primary, orientation, intensity=(amount_a + amount_b) * 0.5, boost=0.08, ) continue coverage = _scan_band_amount( center_progress, phase, min_progress, max_progress, context.params.on_width, context.params.off_width, context.params.scan_style, ) fill = off_color.mix(primary, coverage) result[tile.tile_id] = _tile_sample(fill, primary, intensity=coverage, boost=0.08) return result class ArrowPattern(BasePattern): descriptor = PatternDescriptor( "arrow", "Arrow", "Discrete chevrons like > > on the low-res wall.", ("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"), temporal_profile="direct", ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} horizontal = context.params.direction not in {"top_to_bottom", "bottom_to_top"} major_count = max(1, context.cols if horizontal else context.rows) minor_count = max(1, context.rows if horizontal else context.cols) middle_minor = (minor_count - 1) / 2.0 gap = max(0, int(round(context.params.block_size - 1.0))) span = 3 + gap movement = int(math.floor(context.pattern_tempo_phase)) def row_band(minor_index: int) -> int: if minor_count <= 1: return 0 return 0 if abs(minor_index - middle_minor) <= 0.55 else 1 def chevron_target(orientation: str, minor_index: int) -> int: band = row_band(minor_index) if orientation in {"right", "down"}: return 1 if band == 0 else 0 return 1 if band == 0 else 2 def cell_active(local_index: int, minor_index: int, orientation: str) -> bool: if local_index >= 3: return False return local_index == chevron_target(orientation, minor_index) half_size = max(1, math.ceil(major_count / 2)) for tile in context.sorted_tiles(): major_index = int(tile.col - 1 if horizontal else tile.row - 1) minor_index = int(tile.row - 1 if horizontal else tile.col - 1) sample_amount = major_index / max(1, major_count - 1) primary, _secondary = _sample_for_tile(context, sample_amount, tile.row - 1, tile.col - 1) if context.params.direction in {"outward", "inward"}: left_half = major_index < half_size local_major = major_index if left_half else major_index - half_size if context.params.direction == "outward": orientation = "left" if left_half else "right" local_index = (local_major + movement) % span if left_half else (local_major - movement) % span else: orientation = "right" if left_half else "left" local_index = (local_major - movement) % span if left_half else (local_major + movement) % span else: orientation = "right" if context.params.direction in {"left_to_right", "top_to_bottom"} else "left" local_index = (major_index - movement) % span if orientation == "right" else (major_index + movement) % span active = cell_active(local_index, minor_index, orientation) fill = primary if active else RGBColor.black() result[tile.tile_id] = _tile_sample(fill, primary if active else RGBColor.black(), intensity=1.0 if active else 0.0, boost=0.06 if active else 0.0) return result class ScanDualPattern(BasePattern): descriptor = PatternDescriptor( "scan_dual", "Scan Dual", "Mirrored scanner bands inspired by WLED Scan Dual.", ("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"} axis_count = max(1, context.rows if vertical else context.cols) scan = oscillate(context.pattern_tempo_phase, 0.6) * (axis_count - 1) if context.params.direction in {"right_to_left", "bottom_to_top"}: scan = (axis_count - 1) - scan mirror = (axis_count - 1) - scan width = max(0.3, context.params.block_size * 0.28) for tile in context.sorted_tiles(): pos = float(tile.row - 1 if vertical else tile.col - 1) lead = 1.0 - smoothstep(width, width + 1.0, abs(pos - scan)) echo = (1.0 - smoothstep(width, width + 1.0, abs(pos - mirror))) * 0.62 sample_amount = pos / max(1, axis_count - 1) primary, secondary = _sample_for_tile(context, sample_amount, tile.row - 1, tile.col - 1) lead_fill = _blend_colors(primary, secondary, lead, floor=0.08) echo_fill = _blend_colors(primary, secondary, echo, floor=0.02) if lead >= echo: fill = lead_fill amount = lead else: fill = echo_fill amount = echo result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.12 + amount * 0.88) return result class SweepPattern(BasePattern): descriptor = PatternDescriptor( "sweep", "Sweep", "Primary and secondary colors wipe through the wall like WLED Sweep.", ("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"} axis_count = max(1, context.rows if vertical else context.cols) softness = 0.26 if context.params.direction in {"outward", "inward"}: center = (axis_count - 1) / 2.0 front = oscillate(context.pattern_tempo_phase, 0.45) * center if context.params.direction == "inward": front = center - front for tile in context.sorted_tiles(): pos = float(tile.col - 1 if not vertical else tile.row - 1) distance = abs(pos - center) amount = 1.0 - smoothstep(front, front + softness + 0.6, distance) primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1) fill = secondary.mix(primary, clamp(amount)) result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.2 + clamp(amount) * 0.8) return result front = oscillate(context.pattern_tempo_phase, 0.45) * (axis_count - 1) if context.params.direction in {"right_to_left", "bottom_to_top"}: front = (axis_count - 1) - front for tile in context.sorted_tiles(): pos = float(tile.row - 1 if vertical else tile.col - 1) if context.params.direction in {"right_to_left", "bottom_to_top"}: amount = smoothstep(front - softness, front + softness, pos) else: amount = 1.0 - smoothstep(front - softness, front + softness, pos) primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1) fill = secondary.mix(primary, clamp(amount)) result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.18 + clamp(amount) * 0.82) return result class SawPattern(BasePattern): descriptor = PatternDescriptor( "saw", "Saw", "A stepped saw-wave sweep inspired by WLED's sharper motion effects.", ("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} phase = context.pattern_tempo_phase * 0.7 quantization = max(context.cols, context.rows) for tile in context.sorted_tiles(): amount = _directional_amount(context, tile.row - 1, tile.col - 1) if context.params.direction in {"left_to_right", "right_to_left"}: amount = _mirror_position(amount, context.params.symmetry in {"horizontal", "both"}) if context.params.direction in {"top_to_bottom", "bottom_to_top"}: amount = _mirror_position(amount, context.params.symmetry in {"vertical", "both"}) wave = (amount - phase) % 1.0 saw = wave / 0.92 if wave < 0.92 else 0.0 saw = round(saw * quantization) / max(1, quantization) primary, secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1) if context.params.color_mode == "mono": fill = primary.scaled(saw) intensity = saw else: fill = _blend_colors(primary, secondary, saw, floor=0.04) intensity = 0.16 + saw * 0.84 result[tile.tile_id] = _tile_sample(fill, primary, intensity=intensity) return result class TwoDotsPattern(BasePattern): descriptor = PatternDescriptor( "two_dots", "Two Dots", "Two mirrored highlights travel across the wall, inspired by WLED Two Dots.", ("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"} axis_count = max(1, context.rows if vertical else context.cols) orbit = oscillate(context.pattern_tempo_phase, 0.75) * (axis_count - 1) if context.params.direction in {"right_to_left", "bottom_to_top"}: orbit = (axis_count - 1) - orbit dot_a = orbit dot_b = (axis_count - 1) - orbit width = max(0.25, context.params.block_size * 0.22) for tile in context.sorted_tiles(): pos = float(tile.row - 1 if vertical else tile.col - 1) pulse_a = 1.0 - smoothstep(width, width + 0.95, abs(pos - dot_a)) pulse_b = 1.0 - smoothstep(width, width + 0.95, abs(pos - dot_b)) amount = max(pulse_a, pulse_b) primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1) fill = _blend_colors(primary, secondary, amount, floor=0.05) result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.18 + amount * 0.82) return result