Files

265 lines
12 KiB
Python

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