366 lines
16 KiB
Python
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
|