from __future__ import annotations import math from app.core.colors import ease_in_out_sine, oscillate, sample_palette, smoothstep from app.core.types import RGBColor, TilePatternSample, clamp from ..base import BasePattern, PatternContext, PatternDescriptor from .common import ( _diagonal_split_sample, _directional_amount, _mirror_position, _sample_for_cycle, _sample_for_tile, _temporal_noise, _tile_sample, _with_led_pixels, ) _SEGMENT_NEIGHBOR_OFFSETS: dict[str, tuple[int, int]] = { "left": (0, -1), "right": (0, 1), "top": (-1, 0), "bottom": (1, 0), } def _front_position(phase: float, max_distance: int, reverse: bool) -> float: if max_distance <= 0: return 0.0 front = (phase * 1.35) % (max_distance + 1.0) return max_distance - front if reverse else front def _center_distances(context: PatternContext) -> tuple[dict[tuple[int, int], int], int]: center_rows = [context.rows // 2] if context.rows % 2 == 1 else [max(0, context.rows // 2 - 1), context.rows // 2] center_cols = [context.cols // 2] if context.cols % 2 == 1 else [max(0, context.cols // 2 - 1), context.cols // 2] centers = {(row, col) for row in center_rows for col in center_cols} distances = { (tile.row - 1, tile.col - 1): min(abs((tile.row - 1) - center_row) + abs((tile.col - 1) - center_col) for center_row, center_col in centers) for tile in context.sorted_tiles() } return distances, max(distances.values(), default=0) def _outline_depths(context: PatternContext) -> tuple[dict[tuple[int, int], int], int]: depths = { (tile.row - 1, tile.col - 1): min(tile.row - 1, tile.col - 1, context.rows - tile.row, context.cols - tile.col) for tile in context.sorted_tiles() } return depths, max(depths.values(), default=0) def _outline_led_pixels(context: PatternContext, tile, depths: dict[tuple[int, int], int], depth: int, color: RGBColor) -> dict[str, list[RGBColor]]: pixels: dict[str, list[RGBColor]] = {} row_index = tile.row - 1 col_index = tile.col - 1 black = RGBColor.black() for segment in tile.segments: delta = _SEGMENT_NEIGHBOR_OFFSETS.get(segment.side) active = False if delta is not None: neighbor = (row_index + delta[0], col_index + delta[1]) active = depths.get(neighbor, -1) < depth pixels[segment.name] = [color if active else black for _ in range(segment.led_count)] return pixels class SolidPattern(BasePattern): descriptor = PatternDescriptor( "solid", "Solid", "Uniform wash across the whole wall.", ("brightness", "color_mode", "primary_color", "secondary_color", "palette"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: primary, _secondary = _sample_for_tile(context, 0.15) return {tile.tile_id: _tile_sample(primary, primary) for tile in context.sorted_tiles()} class CheckerPattern(BasePattern): descriptor = PatternDescriptor( "checker", "Checkerd", "Alternating dual-color checkerboard.", ("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "checker_mode"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: phase = int(math.floor(context.pattern_tempo_phase)) checker_mode = context.params.checker_mode result: dict[str, TilePatternSample] = {} for tile in context.sorted_tiles(): row_index = tile.row - 1 col_index = tile.col - 1 amount = (tile.col - 1) / max(1, context.cols - 1) primary, secondary = _sample_for_tile(context, amount, row_index, col_index) parity = (row_index + col_index) % 2 if checker_mode == "diagonal": primary_first = (parity + phase) % 2 == 0 orientation = "backslash" color_a = primary if primary_first else secondary color_b = secondary if primary_first else primary result[tile.tile_id] = _diagonal_split_sample(color_a, color_b, primary, orientation, boost=0.06) continue if checker_mode == "checkerd": orientation = "backslash" if phase % 2 == 0 else "slash" color_a = primary if parity == 0 else secondary color_b = secondary if parity == 0 else primary result[tile.tile_id] = _diagonal_split_sample(color_a, color_b, primary, orientation, boost=0.06) continue fill = primary if (parity + phase) % 2 == 0 else secondary accent = secondary if fill == primary else primary result[tile.tile_id] = _tile_sample(fill, accent) return result class RowGradientPattern(BasePattern): descriptor = PatternDescriptor( "row_gradient", "Row Gradient", "Vertical blend across the rows.", ("brightness", "fade", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} for tile in context.sorted_tiles(): row_amount = (tile.row - 1) / max(1, context.rows - 1) if context.params.direction == "bottom_to_top": row_amount = 1.0 - row_amount row_amount = _mirror_position(row_amount, context.params.symmetry in {"vertical", "both"}) primary, secondary = _sample_for_tile(context, row_amount, tile.row - 1, tile.col - 1) fill = secondary.mix(primary, row_amount) result[tile.tile_id] = _tile_sample(fill, primary) return result class ColumnGradientPattern(BasePattern): descriptor = PatternDescriptor( "column_gradient", "Column Gradient", "Horizontal blend across the columns.", ("brightness", "fade", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} for tile in context.sorted_tiles(): amount = _directional_amount(context, tile.row - 1, tile.col - 1) amount = _mirror_position(amount, context.params.symmetry in {"horizontal", "both"}) primary, secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1) fill = secondary.mix(primary, amount) result[tile.tile_id] = _tile_sample(fill, primary) return result class CenterPulsePattern(BasePattern): descriptor = PatternDescriptor( "center_pulse", "Center Pulse", "Radial waves expanding from the center.", ("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "center_pulse_mode"), temporal_profile="direct", ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: mode = context.params.center_pulse_mode if mode in {"outline", "outline_reverse"}: return self._render_outline(context, reverse=mode == "outline_reverse") return self._render_fill(context, reverse=mode == "reverse") def _render_fill(self, context: PatternContext, *, reverse: bool) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} distances, max_distance = _center_distances(context) front = _front_position(context.pattern_tempo_phase, max_distance, reverse) for tile in context.sorted_tiles(): tile_distance = float(distances[(tile.row - 1, tile.col - 1)]) amount = 1.0 - smoothstep(0.0, 0.7, abs(tile_distance - front)) primary, secondary = _sample_for_tile(context, tile_distance / max(1, max_distance), tile.row - 1, tile.col - 1) if context.params.color_mode == "mono": fill = primary.scaled(amount) else: fill = secondary.mix(primary, amount).scaled(amount) result[tile.tile_id] = _tile_sample(fill, primary, intensity=amount, boost=0.1) return result def _render_outline(self, context: PatternContext, *, reverse: bool) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} depths, max_depth = _outline_depths(context) front = _front_position(context.pattern_tempo_phase, max_depth, reverse) for tile in context.sorted_tiles(): row_index = tile.row - 1 col_index = tile.col - 1 depth = depths[(row_index, col_index)] ring_index = max_depth - depth amount = 1.0 - smoothstep(0.0, 0.7, abs(ring_index - front)) primary, _secondary = _sample_for_tile(context, ring_index / max(1, max_depth), row_index, col_index) led_color = primary.scaled(amount) led_pixels = _outline_led_pixels(context, tile, depths, depth, led_color) lit_leds = sum( 1 for segment_pixels in led_pixels.values() for color in segment_pixels if color.to_8bit_tuple() != (0, 0, 0) ) total_leds = max(1, sum(len(segment_pixels) for segment_pixels in led_pixels.values())) preview_level = amount * (lit_leds / total_leds) fill = primary.scaled(preview_level * 0.4) if lit_leds else RGBColor.black() sample = _tile_sample(fill, primary, intensity=amount if lit_leds else 0.0, boost=0.08) result[tile.tile_id] = _with_led_pixels(sample, led_pixels) return result class SparklePattern(BasePattern): descriptor = PatternDescriptor( "sparkle", "Sparkle", "Ambient base layer with random sparkling accents.", ("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "strobe_duty_cycle"), temporal_profile="direct", ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98) time_bucket = context.pattern_tempo_phase * 7.0 bucket_index = math.floor(time_bucket) for tile in context.sorted_tiles(): base_amount = (tile.col - 1) / max(1, context.cols - 1) primary, _secondary = _sample_for_cycle(context, base_amount, bucket_index * 29.7 + 4.2) sparkle = _temporal_noise((tile.row * 17.13) + (tile.col * 11.7) + math.floor(time_bucket)) burst = smoothstep(1.0 - density, 1.0, sparkle) visible_burst = burst if burst >= 0.05 else 0.0 fill = primary.scaled(visible_burst) result[tile.tile_id] = _tile_sample(fill, primary, intensity=visible_burst, boost=0.08 + visible_burst * 0.2) return result class BreathingPattern(BasePattern): descriptor = PatternDescriptor( "breathing", "Breathing", "Slow collective inhale and exhale.", ("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette"), ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} breathe = ease_in_out_sine(oscillate(context.pattern_tempo_phase, 0.25)) primary, secondary = _sample_for_tile(context, breathe) base_fill = secondary.mix(primary, breathe) for tile in context.sorted_tiles(): fill = base_fill if context.params.color_mode == "palette": palette_color = sample_palette(context.params.palette, (tile.col - 1) / max(1, context.cols - 1)) fill = fill.mix(palette_color, 0.18) result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.24 + breathe * 0.76, boost=0.18) return result