First upload, 18 controller version
This commit is contained in:
2
app/patterns/__init__.py
Normal file
2
app/patterns/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Pattern registry and built-in pattern implementations."""
|
||||
|
||||
BIN
app/patterns/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/patterns/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/patterns/__pycache__/base.cpython-310.pyc
Normal file
BIN
app/patterns/__pycache__/base.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/patterns/__pycache__/builtin.cpython-310.pyc
Normal file
BIN
app/patterns/__pycache__/builtin.cpython-310.pyc
Normal file
Binary file not shown.
276
app/patterns/base.py
Normal file
276
app/patterns/base.py
Normal 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.",
|
||||
),
|
||||
}
|
||||
66
app/patterns/builtin/__init__.py
Normal file
66
app/patterns/builtin/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
app/patterns/builtin/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/patterns/builtin/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/patterns/builtin/__pycache__/common.cpython-310.pyc
Normal file
BIN
app/patterns/builtin/__pycache__/common.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/patterns/builtin/__pycache__/fills.cpython-310.pyc
Normal file
BIN
app/patterns/builtin/__pycache__/fills.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/patterns/builtin/__pycache__/motion.cpython-310.pyc
Normal file
BIN
app/patterns/builtin/__pycache__/motion.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/patterns/builtin/__pycache__/special.cpython-310.pyc
Normal file
BIN
app/patterns/builtin/__pycache__/special.cpython-310.pyc
Normal file
Binary file not shown.
212
app/patterns/builtin/common.py
Normal file
212
app/patterns/builtin/common.py
Normal 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
|
||||
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
|
||||
365
app/patterns/builtin/motion.py
Normal file
365
app/patterns/builtin/motion.py
Normal 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
|
||||
328
app/patterns/builtin/special.py
Normal file
328
app/patterns/builtin/special.py
Normal 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
|
||||
Reference in New Issue
Block a user