Files

366 lines
16 KiB
Python

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