First upload, 18 controller version
This commit is contained in:
264
app/patterns/builtin/fills.py
Normal file
264
app/patterns/builtin/fills.py
Normal file
@@ -0,0 +1,264 @@
|
||||
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
|
||||
Reference in New Issue
Block a user