First upload, 18 controller version

This commit is contained in:
2026-04-14 15:23:56 +02:00
commit 8c55001a1c
3810 changed files with 764061 additions and 0 deletions

2
app/patterns/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Pattern registry and built-in pattern implementations."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

276
app/patterns/base.py Normal file
View File

@@ -0,0 +1,276 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Iterable
from app.config.models import InfinityMirrorConfig, TileConfig
from app.core.types import PatternParameters, TilePatternSample, clamp
@dataclass(frozen=True)
class ParameterSpec:
key: str
label: str
kind: str
minimum: float = 0.0
maximum: float = 1.0
step: float = 0.01
reset_value: float | None = None
options: tuple[tuple[str, str], ...] = ()
tooltip: str = ""
@dataclass(frozen=True)
class PatternDescriptor:
pattern_id: str
display_name: str
description: str
supported_parameters: tuple[str, ...]
accent_hex: str = "#4D7CFF"
temporal_profile: str = "smooth"
@dataclass
class PatternContext:
config: InfinityMirrorConfig
params: PatternParameters
time_s: float
tempo_bpm: float = 60.0
tempo_phase: float = 0.0
@property
def rows(self) -> int:
return self.config.logical_display.rows
@property
def cols(self) -> int:
return self.config.logical_display.cols
def sorted_tiles(self) -> list[TileConfig]:
return self.config.sorted_tiles()
@property
def tempo_hz(self) -> float:
return max(0.05, float(self.tempo_bpm) / 60.0)
@property
def tempo_multiplier(self) -> float:
return clamp(float(self.params.tempo_multiplier), 0.25, 8.0)
@property
def pattern_tempo_hz(self) -> float:
return self.tempo_hz * self.tempo_multiplier
@property
def pattern_tempo_phase(self) -> float:
return self.tempo_phase * self.tempo_multiplier
class BasePattern(ABC):
descriptor: PatternDescriptor
@abstractmethod
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
raise NotImplementedError
class PatternRegistry:
def __init__(self, patterns: Iterable[BasePattern]) -> None:
self._patterns = {pattern.descriptor.pattern_id: pattern for pattern in patterns}
def get(self, pattern_id: str) -> BasePattern:
return self._patterns[pattern_id]
def descriptors(self) -> list[PatternDescriptor]:
return [self._patterns[key].descriptor for key in sorted(self._patterns)]
def ids(self) -> list[str]:
return list(sorted(self._patterns))
COMMON_PARAMETER_SPECS: dict[str, ParameterSpec] = {
"brightness": ParameterSpec("brightness", "Brightness", "slider", 0.0, 2.0, 0.01, reset_value=1.0, tooltip="Pattern output level."),
"fade": ParameterSpec("fade", "Smoothing", "slider", 0.0, 1.0, 0.01, reset_value=0.0, tooltip="Higher values create softer transitions."),
"tempo_multiplier": ParameterSpec(
"tempo_multiplier",
"Tempo Multiplier",
"slider",
0.25,
8.0,
0.05,
reset_value=1.0,
tooltip="Scales this pattern relative to the global BPM.",
),
"direction": ParameterSpec(
"direction",
"Direction",
"combo",
options=(
("left_to_right", "Left to Right"),
("right_to_left", "Right to Left"),
("top_to_bottom", "Top to Bottom"),
("bottom_to_top", "Bottom to Top"),
("outward", "Outward"),
("inward", "Inward"),
),
tooltip="Primary motion direction.",
),
"checker_mode": ParameterSpec(
"checker_mode",
"Checker Mode",
"combo",
options=(
("classic", "Classic"),
("diagonal", "Diagonal Split"),
("checkerd", "Checkerd"),
),
tooltip="Classic checker, diagonal half-pixels, or diagonal flip animation.",
),
"scan_style": ParameterSpec(
"scan_style",
"Scan Style",
"combo",
options=(
("line", "Line"),
("bands", "Bands"),
),
tooltip="Single moving scan band or repeating band pattern.",
),
"angle": ParameterSpec(
"angle",
"Angle",
"angle",
minimum=0.0,
maximum=315.0,
step=45.0,
tooltip="Scan direction in 45 degree steps.",
),
"on_width": ParameterSpec(
"on_width",
"On Width",
"slider",
0.1,
2.0,
0.05,
tooltip="Length of the active scan window.",
),
"off_width": ParameterSpec(
"off_width",
"Off Width",
"slider",
0.1,
2.0,
0.05,
tooltip="Gap between active scan windows.",
),
"band_thickness": ParameterSpec(
"band_thickness",
"Band Thickness",
"slider",
0.1,
2.0,
0.05,
tooltip="Visible thickness of the lit band inside the active window.",
),
"flip_horizontal": ParameterSpec(
"flip_horizontal",
"Flip Horizontal",
"checkbox",
tooltip="Mirror scan evaluation left-to-right for installation alignment.",
),
"flip_vertical": ParameterSpec(
"flip_vertical",
"Flip Vertical",
"checkbox",
tooltip="Mirror scan evaluation top-to-bottom for installation alignment.",
),
"strobe_mode": ParameterSpec(
"strobe_mode",
"Strobe Mode",
"combo",
options=(
("global", "Global"),
("random_pixels", "Random Pixels"),
("random_leds", "Random LEDs"),
),
tooltip="Whole-wall strobe, grouped random pixel blocks, or fully shuffled per-LED timing.",
),
"stopwatch_mode": ParameterSpec(
"stopwatch_mode",
"Stopwatch Mode",
"combo",
options=(
("sync", "Sync"),
("random", "Random"),
),
tooltip="Run all tiles together or with deterministic random offsets.",
),
"color_mode": ParameterSpec(
"color_mode",
"Color Mode",
"combo",
options=(
("dual", "Dual"),
("palette", "Palette"),
("mono", "Mono"),
("complementary", "Complementary"),
("random_colors", "Random Colors"),
("custom_random", "Custom Random"),
),
tooltip="How colors are chosen for the pattern.",
),
"primary_color": ParameterSpec("primary_color", "Primary Color", "color", tooltip="Main color."),
"secondary_color": ParameterSpec("secondary_color", "Secondary Color", "color", tooltip="Secondary color."),
"palette": ParameterSpec("palette", "Palette", "combo", tooltip="Palette for palette-driven patterns."),
"symmetry": ParameterSpec(
"symmetry",
"Mirror",
"combo",
options=(("none", "None"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("both", "Both")),
tooltip="Mirrors pattern coordinates around the center.",
),
"center_pulse_mode": ParameterSpec(
"center_pulse_mode",
"Pulse Mode",
"combo",
options=(
("expand", "Expand"),
("reverse", "Reverse"),
("outline", "Outline"),
("outline_reverse", "Outline Reverse"),
),
tooltip="Expand from the center, run inward, or use only the rectangular outline rings.",
),
"block_size": ParameterSpec("block_size", "Block Size", "slider", 0.1, 6.0, 0.1, tooltip="Width of active bands."),
"pixel_group_size": ParameterSpec(
"pixel_group_size",
"Pixel Group",
"slider",
1.0,
5.0,
1.0,
reset_value=1.0,
tooltip="Treat several adjacent LEDs as one strobe pixel.",
),
"strobe_duty_cycle": ParameterSpec(
"strobe_duty_cycle",
"Duty / Density",
"slider",
0.005,
0.98,
0.005,
reset_value=0.5,
tooltip="Controls strobe on-time or sparkle fill density depending on the pattern.",
),
"randomness": ParameterSpec(
"randomness",
"Randomness",
"slider",
0.0,
1.5,
0.01,
reset_value=0.35,
tooltip="Controls variation in patterns that intentionally use randomness.",
),
}

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from ..base import BasePattern
from .fills import (
BreathingPattern,
CenterPulsePattern,
CheckerPattern,
ColumnGradientPattern,
RowGradientPattern,
SolidPattern,
SparklePattern,
)
from .motion import (
ArrowPattern,
SawPattern,
ScanDualPattern,
ScanPattern,
SweepPattern,
TwoDotsPattern,
WaveLinePattern,
)
from .special import SnakePattern, StopwatchPattern, StrobePattern
def built_in_patterns() -> list[BasePattern]:
return [
ArrowPattern(),
BreathingPattern(),
CenterPulsePattern(),
CheckerPattern(),
ColumnGradientPattern(),
RowGradientPattern(),
SawPattern(),
ScanPattern(),
ScanDualPattern(),
SnakePattern(),
SolidPattern(),
SparklePattern(),
StopwatchPattern(),
StrobePattern(),
SweepPattern(),
TwoDotsPattern(),
WaveLinePattern(),
]
__all__ = [
"ArrowPattern",
"BreathingPattern",
"CenterPulsePattern",
"CheckerPattern",
"ColumnGradientPattern",
"RowGradientPattern",
"SawPattern",
"ScanDualPattern",
"ScanPattern",
"SnakePattern",
"SolidPattern",
"SparklePattern",
"StopwatchPattern",
"StrobePattern",
"SweepPattern",
"TwoDotsPattern",
"WaveLinePattern",
"built_in_patterns",
]

Binary file not shown.

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import math
from app.core.colors import (
brighten,
choose_pair,
custom_random_color_choices,
label_contrast,
sample_random_effect_color,
)
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import PatternContext
def _mirror_position(position: float, enabled: bool) -> float:
return min(position, 1.0 - position) * 2.0 if enabled else position
def _directional_amount(context: PatternContext, row_index: int, col_index: int) -> float:
rows = max(1, context.rows - 1)
cols = max(1, context.cols - 1)
row_position = row_index / rows
col_position = col_index / cols
direction = context.params.direction
if direction == "right_to_left":
return 1.0 - col_position
if direction == "top_to_bottom":
return row_position
if direction == "bottom_to_top":
return 1.0 - row_position
if direction == "outward":
return abs(col_position - 0.5) * 2.0
if direction == "inward":
return 1.0 - abs(col_position - 0.5) * 2.0
return col_position
def _random_color_pair(context: PatternContext, seed: float) -> tuple[RGBColor, RGBColor]:
primary = _random_vivid_color(seed)
secondary = primary.scaled(0.08)
return primary, secondary
def _custom_random_color_pair(context: PatternContext, seed: float) -> tuple[RGBColor, RGBColor]:
choices = custom_random_color_choices(context.params.primary_color, context.params.secondary_color)
primary = choices[int(_temporal_noise(seed) * len(choices)) % len(choices)]
secondary = primary.scaled(0.08)
return primary, secondary
def _spatial_color_seed(amount: float, row_index: int, col_index: int) -> float:
return (
amount * 7.31
+ (row_index + 1) * 0.613
+ (col_index + 1) * 1.137
+ (row_index + 1) * (col_index + 1) * 0.071
)
def _sample_for_tile(
context: PatternContext,
amount: float,
row_index: int | None = None,
col_index: int | None = None,
) -> tuple[RGBColor, RGBColor]:
if context.params.color_mode == "random_colors":
return _random_color_pair(context, _spatial_color_seed(amount, row_index or 0, col_index or 0))
if context.params.color_mode == "custom_random":
return _custom_random_color_pair(context, _spatial_color_seed(amount, row_index or 0, col_index or 0))
return choose_pair(
context.params.color_mode,
context.params.primary_color,
context.params.secondary_color,
context.params.palette,
amount,
)
def _sample_for_cycle(context: PatternContext, amount: float, seed: float) -> tuple[RGBColor, RGBColor]:
if context.params.color_mode == "random_colors":
return _random_color_pair(context, seed + amount * 3.1)
if context.params.color_mode == "custom_random":
return _custom_random_color_pair(context, seed + amount * 3.1)
return _sample_for_tile(context, amount)
def _blend_colors(primary: RGBColor, secondary: RGBColor, amount: float, floor: float = 0.0) -> RGBColor:
floor = clamp(floor)
return secondary.mix(primary, floor + (1.0 - floor) * clamp(amount))
def _tile_sample(fill: RGBColor, accent: RGBColor, intensity: float = 1.0, boost: float = 0.1) -> TilePatternSample:
intensity = clamp(intensity)
if intensity <= 0.0 and fill.to_8bit_tuple() == (0, 0, 0):
glow = RGBColor.black()
rim = RGBColor.black()
else:
glow = brighten(fill, boost)
rim = fill.mix(accent, 0.24)
return TilePatternSample(
fill_color=fill,
glow_color=glow,
rim_color=rim,
label_color=label_contrast(fill),
intensity=intensity,
)
def _diagonal_split_sample(
color_a: RGBColor,
color_b: RGBColor,
accent: RGBColor,
orientation: str,
intensity: float = 1.0,
boost: float = 0.1,
) -> TilePatternSample:
sample = _tile_sample(color_a.mix(color_b, 0.5), accent, intensity=intensity, boost=boost)
sample.metadata["diagonal_split"] = {
"orientation": orientation,
"color_a": color_a,
"color_b": color_b,
}
return sample
def _with_led_pixels(sample: TilePatternSample, led_pixels: dict[str, list[RGBColor]]) -> TilePatternSample:
sample.metadata["led_pixels"] = led_pixels
return sample
def _noise(value: float) -> float:
return value - math.floor(value)
def _temporal_noise(seed: float) -> float:
return _noise(math.sin(seed * 12.9898) * 43758.5453)
def _random_vivid_color(seed: float) -> RGBColor:
return sample_random_effect_color(_temporal_noise(seed))
def _axis_data(context: PatternContext, row_index: int, col_index: int) -> tuple[float, int, bool]:
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
position = float(row_index if vertical else col_index)
count = context.rows if vertical else context.cols
return position, max(1, count), vertical
_SCAN_VECTORS: dict[int, tuple[int, int]] = {
0: (1, 0),
45: (1, 1),
90: (0, 1),
135: (-1, 1),
180: (-1, 0),
225: (-1, -1),
270: (0, -1),
315: (1, -1),
}
def _scan_vector(angle: float) -> tuple[int, int]:
return _SCAN_VECTORS[int(angle) % 360]
def _scan_point(context: PatternContext, row_index: int, col_index: int, local_x: float, local_y: float) -> tuple[float, float]:
return col_index + local_x, row_index + local_y
def _scan_projection(
context: PatternContext,
row_index: int,
col_index: int,
local_x: float,
local_y: float,
vector: tuple[int, int],
) -> float:
x_pos, y_pos = _scan_point(context, row_index, col_index, local_x, local_y)
return x_pos * vector[0] + y_pos * vector[1]
def _scan_bounds(context: PatternContext, vector: tuple[int, int]) -> tuple[float, float]:
corners = (
(0.0, 0.0),
(float(context.cols), 0.0),
(0.0, float(context.rows)),
(float(context.cols), float(context.rows)),
)
projections = [x_pos * vector[0] + y_pos * vector[1] for x_pos, y_pos in corners]
return min(projections), max(projections)
def _scan_band_amount(
progress: float,
phase: float,
min_progress: float,
max_progress: float,
on_width: float,
off_width: float,
scan_style: str,
) -> float:
if scan_style == "bands":
period = max(0.1, on_width + off_width)
local = (progress - min_progress - phase) % period
return 1.0 if local < on_width else 0.0
travel = max(0.1, (max_progress - min_progress) + on_width + max(0.0, off_width))
band_center = min_progress + (phase % travel)
return 1.0 if abs(progress - band_center) <= on_width * 0.5 else 0.0

View 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

View File

@@ -0,0 +1,365 @@
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

View File

@@ -0,0 +1,328 @@
from __future__ import annotations
import math
import random
from app.core.colors import brighten, sample_palette, smoothstep
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import BasePattern, PatternContext, PatternDescriptor
from .common import (
_random_vivid_color,
_sample_for_cycle,
_sample_for_tile,
_temporal_noise,
_tile_sample,
_with_led_pixels,
)
class StrobePattern(BasePattern):
descriptor = PatternDescriptor(
"strobe",
"Strobe",
"Fast on/off pulses with duty-cycle control.",
(
"brightness",
"fade",
"tempo_multiplier",
"strobe_mode",
"color_mode",
"primary_color",
"secondary_color",
"palette",
"pixel_group_size",
"strobe_duty_cycle",
),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
if context.params.strobe_mode == "random_pixels":
return self._render_random_pixels(context, grouped=True)
if context.params.strobe_mode == "random_leds":
return self._render_random_pixels(context, grouped=False)
phase = context.pattern_tempo_phase * 4.0
bucket = math.floor(phase)
on = phase % 1.0 < context.params.strobe_duty_cycle
primary, _secondary = _sample_for_cycle(context, context.time_s * 0.1, bucket * 17.1 + 3.0)
if on:
return {tile.tile_id: _tile_sample(primary, primary, intensity=1.0) for tile in context.sorted_tiles()}
black = RGBColor.black()
return {tile.tile_id: _tile_sample(black, black, intensity=0.0, boost=0.0) for tile in context.sorted_tiles()}
def _render_random_pixels(self, context: PatternContext, *, grouped: bool) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
bucket = math.floor(context.pattern_tempo_phase * 10.0)
density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98)
pixel_group_size = max(1, min(5, int(round(context.params.pixel_group_size)))) if grouped else 1
for tile in context.sorted_tiles():
amount = (tile.col - 1) / max(1, context.cols - 1)
primary, _secondary = _sample_for_cycle(context, amount, bucket * 29.7 + tile.row * 11.7 + tile.col * 17.9)
led_pixels: dict[str, list[RGBColor]] = {}
lit_leds = 0
total_leds = 0
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
count = max(1, segment.led_count)
for group_start in range(0, count, pixel_group_size):
group_index = group_start // pixel_group_size
group_end = min(count, group_start + pixel_group_size)
seed = bucket * 19.7 + tile.row * 31.3 + tile.col * 17.9 + segment.start_channel * 0.11 + group_index * 1.73
active = _temporal_noise(seed) < density
if context.params.color_mode == "palette":
color = sample_palette(context.params.palette, (amount + group_start / max(1, count - 1) * 0.2) % 1.0)
else:
color = primary
for _index in range(group_start, group_end):
segment_pixels.append(color if active else RGBColor.black())
lit_leds += 1 if active else 0
total_leds += 1
led_pixels[segment.name] = segment_pixels
activity = lit_leds / max(1, total_leds)
preview_level = clamp(activity * 8.0, 0.0, 1.0)
fill = primary.scaled(preview_level) if lit_leds else RGBColor.black()
sample = _tile_sample(fill, primary, intensity=preview_level, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class StopwatchPattern(BasePattern):
descriptor = PatternDescriptor(
"stopwatch",
"Stopwatch",
"LEDs fill from 1 to N and then clear from N back to 1 on every tile.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "stopwatch_mode"),
)
def __init__(self) -> None:
self._last_base_phase_position: float | None = None
def _tile_led_count(self, tile) -> int:
return max(1, sum(segment.led_count for segment in tile.segments))
def _tile_cycle_color(self, context: PatternContext, tile, cycle_index: int) -> RGBColor:
amount = (tile.col - 1 + tile.row - 1) / max(1, context.rows + context.cols - 2)
if context.params.color_mode == "random_colors":
return _random_vivid_color(cycle_index * 53.1 + tile.row * 11.7 + tile.col * 17.9)
primary, _secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
return primary
def _crossed_full_count_peak(self, previous_position: float, current_position: float, cycle_length: int, led_count: int) -> bool:
if current_position <= previous_position or cycle_length <= 0:
return False
next_peak = math.floor(previous_position / cycle_length) * cycle_length + led_count
if next_peak < previous_position:
next_peak += cycle_length
return next_peak <= current_position
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
base_phase_position = context.pattern_tempo_phase * 20.0
previous_base_phase_position = self._last_base_phase_position
use_phase_bridge = (
previous_base_phase_position is not None
and previous_base_phase_position <= base_phase_position
and (base_phase_position - previous_base_phase_position) <= 512.0
)
self._last_base_phase_position = base_phase_position
for tile in context.sorted_tiles():
led_count = self._tile_led_count(tile)
cycle_length = max(1, led_count * 2)
offset = 0
if context.params.stopwatch_mode == "random":
offset = int(_temporal_noise(tile.row * 13.7 + tile.col * 23.9) * cycle_length)
tile_phase_position = base_phase_position + offset
cycle_index = int(tile_phase_position // cycle_length)
phase = tile_phase_position % cycle_length
active_count = phase + 1 if phase < led_count else (2 * led_count) - phase
active_count = max(1, min(led_count, int(round(active_count))))
if use_phase_bridge and previous_base_phase_position is not None:
previous_tile_phase = previous_base_phase_position + offset
if self._crossed_full_count_peak(previous_tile_phase, tile_phase_position, cycle_length, led_count):
active_count = led_count
color = self._tile_cycle_color(context, tile, cycle_index)
remaining = active_count
led_pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for _index in range(segment.led_count):
lit = remaining > 0
segment_pixels.append(color if lit else RGBColor.black())
if lit:
remaining -= 1
led_pixels[segment.name] = segment_pixels
activity = active_count / max(1, led_count)
fill = color.scaled(activity)
sample = _tile_sample(fill, color, intensity=activity, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class SnakePattern(BasePattern):
descriptor = PatternDescriptor(
"snake",
"Snake",
"A random self-playing snake roaming across the wall.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "randomness"),
)
def __init__(self) -> None:
self._rng = random.Random(1337)
self._shape: tuple[int, int] | None = None
self._snake: list[tuple[int, int]] = []
self._direction: tuple[int, int] = (0, 1)
self._apple: tuple[int, int] | None = None
self._blink_until_time: float | None = None
self._target_length = 4
self._last_time_s: float | None = None
self._step_progress = 0.0
def _reset(self, rows: int, cols: int) -> None:
start_row = rows // 2
length = max(2, min(self._target_length, cols))
start_col = min(cols - 1, max(length - 1, cols // 2))
self._snake = [(start_row, start_col - index) for index in range(length)]
self._direction = (0, 1)
self._apple = None
self._spawn_apple(rows, cols)
self._blink_until_time = None
self._shape = (rows, cols)
self._last_time_s = None
self._step_progress = 0.0
def _spawn_apple(self, rows: int, cols: int) -> None:
occupied = set(self._snake)
candidates = [(row, col) for row in range(rows) for col in range(cols) if (row, col) not in occupied]
self._apple = self._rng.choice(candidates) if candidates else None
def _neighbors(self, head: tuple[int, int], rows: int, cols: int) -> list[tuple[int, int]]:
row, col = head
neighbors = []
for d_row, d_col in ((0, 1), (1, 0), (0, -1), (-1, 0)):
next_row = row + d_row
next_col = col + d_col
if 0 <= next_row < rows and 0 <= next_col < cols:
neighbors.append((next_row, next_col))
return neighbors
def _manhattan(self, cell_a: tuple[int, int], cell_b: tuple[int, int]) -> int:
return abs(cell_a[0] - cell_b[0]) + abs(cell_a[1] - cell_b[1])
def _turn_left(self, direction: tuple[int, int]) -> tuple[int, int]:
return (-direction[1], direction[0])
def _turn_right(self, direction: tuple[int, int]) -> tuple[int, int]:
return (direction[1], -direction[0])
def _advance(self, rows: int, cols: int, randomness: float, current_time: float) -> None:
if not self._snake:
self._reset(rows, cols)
head = self._snake[0]
occupied = set(self._snake[:-1])
candidate_directions = [
self._direction,
self._turn_left(self._direction),
self._turn_right(self._direction),
]
candidates: list[tuple[tuple[int, int], tuple[int, int]]] = []
for next_direction in candidate_directions:
next_cell = (head[0] + next_direction[0], head[1] + next_direction[1])
if not (0 <= next_cell[0] < rows and 0 <= next_cell[1] < cols):
continue
if next_cell in occupied:
continue
candidates.append((next_cell, next_direction))
if not candidates:
reverse_direction = (-self._direction[0], -self._direction[1])
reverse_cell = (head[0] + reverse_direction[0], head[1] + reverse_direction[1])
if 0 <= reverse_cell[0] < rows and 0 <= reverse_cell[1] < cols and reverse_cell not in occupied:
candidates.append((reverse_cell, reverse_direction))
if not candidates:
self._reset(rows, cols)
return
def openness(cell: tuple[int, int]) -> int:
blocked = set(self._snake[:-2]) if len(self._snake) > 2 else set()
return sum(1 for neighbor in self._neighbors(cell, rows, cols) if neighbor not in blocked)
turniness = max(0.0, min(1.0, randomness / 1.5))
best_cell, best_direction = candidates[0]
best_score = -10_000.0
for cell, next_direction in candidates:
straight_bonus = 2.4 if next_direction == self._direction else 0.0
turn_penalty = -0.55 if next_direction != self._direction else 0.0
apple_bonus = 0.0
if self._apple is not None:
apple_bonus = max(0.0, (rows + cols) - self._manhattan(cell, self._apple)) * 0.7
if cell == self._apple:
apple_bonus += 5.0
score = openness(cell) + straight_bonus + turn_penalty + apple_bonus + self._rng.random() * (0.08 + turniness * 0.45)
if score > best_score:
best_score = score
best_cell = cell
best_direction = next_direction
self._direction = best_direction
self._snake.insert(0, best_cell)
ate_apple = best_cell == self._apple
if ate_apple:
self._blink_until_time = current_time + 0.12
self._spawn_apple(rows, cols)
while len(self._snake) > self._target_length:
self._snake.pop()
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
rows = max(1, context.rows)
cols = max(1, context.cols)
if self._shape != (rows, cols):
self._reset(rows, cols)
delta_s = 0.0
if self._last_time_s is not None:
raw_delta = context.time_s - self._last_time_s
if 0.0 < raw_delta <= 0.5:
delta_s = raw_delta
self._last_time_s = context.time_s
move_rate = context.pattern_tempo_hz * 2.2
self._step_progress += delta_s * move_rate
steps_to_run = min(3, int(self._step_progress))
if steps_to_run:
self._step_progress -= steps_to_run
for _ in range(steps_to_run):
self._advance(rows, cols, context.params.randomness, context.time_s)
if not self._snake:
self._reset(rows, cols)
self._last_time_s = context.time_s
body_lookup = {cell: index for index, cell in enumerate(self._snake)}
blinking = self._blink_until_time is not None and context.time_s <= self._blink_until_time
result: dict[str, TilePatternSample] = {}
for tile in context.sorted_tiles():
row_index = tile.row - 1
col_index = tile.col - 1
primary, secondary = _sample_for_tile(context, 0.0)
if (row_index, col_index) in body_lookup:
is_head = body_lookup[(row_index, col_index)] == 0
fill = brighten(primary, 0.22) if blinking and is_head else primary
result[tile.tile_id] = _tile_sample(fill, primary, intensity=1.0, boost=0.06 if blinking and is_head else 0.03)
elif self._apple == (row_index, col_index):
fill = secondary
accent = brighten(secondary, 0.15)
result[tile.tile_id] = _tile_sample(fill, accent, intensity=0.92, boost=0.03)
else:
fill = RGBColor.black()
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.0, boost=0.0)
return result