265 lines
12 KiB
Python
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
|