Добавить общую модель двухканальных сигналов и анимированных XY-сцен для предпросмотра, экспорта CSV и подготовки синхронных каналов. Построить округлый силуэт кота кривыми Безье, добавить овальные глаза, загнутый хвост и плавную походку: 32 позы по 125 точек, всего 4000 пар. Проверка: 71 тест каталога, диапазонов, CSV и управления генератором.
243 lines
50 KiB
Python
243 lines
50 KiB
Python
"""Двадцать синхронных заданий для двухканального ЦАП и векторного экрана XY.""" # Общая математика не зависит от Qt и используется предпросмотром и готовыми CSV.
|
|
import math # Аналитические траектории и несущие вычисляются в нормированных координатах.
|
|
from dataclasses import dataclass # Неизменяемый результат связывает описание, контуры и общую временную сетку.
|
|
|
|
SAMPLE_RATE = 40_000 # 40 кГц делит без остатка TIM6 72 МГц F407 и 160 МГц G474.
|
|
SAMPLE_COUNT = 1000 # Тысяча пар укладывается в аппаратный предел 4096 и даёт повторение 40 Гц при 40 кГц.
|
|
CAT_SAMPLE_RATE = 4_000 # 4000 пар/с воспроизводят 32 позы по 125 точек за одну секунду: 32 кадра/с и один шаговый цикл в секунду.
|
|
ANIMATION_SAMPLE_RATE = 4_000 # Полный геометрический цикл из 4000 пар длится одну секунду и использует точный делитель TIM6 обоих MCU.
|
|
ANIMATION_MODES = (("auto", "Движение фигуры"), ("twist", "Скручивание"), ("rotate", "Вращение"), ("pulse", "Пульсация"), ("none", "Статично")) # Параметр выбирает изменение координат всей фигуры, а не способ подсветки обходящего её луча.
|
|
WALKING_CAT = ("walking_cat", "Кот идёт · усы, лапы и хвост", "Полный цикл походки: 32 плавные позы, округлые щёки и спина, овальные глаза, четыре лапы, усы и изогнутый хвост.") # Дополнительный анимированный пример не заменяет десять основных XY-фигур.
|
|
BOAT_ON_WAVES = ("boat_waves", "Кораблик на волнах", "Кораблик, мачта, паруса и флажок неподвижны; два ряда волн движутся вправо под корпусом.") # Специальная сцена сохраняет неизменную геометрию кораблика во всех позах движения воды.
|
|
XY_EXTRA_EXAMPLES = (WALKING_CAT, BOAT_ON_WAVES) # Общий список дополнительных сцен синхронизирует каталог UI, готовые CSV и математическую модель.
|
|
DUAL_EXAMPLES = ( # Два канала используют общий период и воспроизводятся синхронно через PA4 и PA5.
|
|
("quadrature", "Синус и косинус · 90°", "Одинаковая частота, квадратурный сдвиг 90°; в XY получается окружность."), # Базовая проверка амплитуды, полярности и синхронности каналов.
|
|
("phase_120", "Два синуса · 120°", "Два синуса со сдвигом 120° для проверки фазовых измерений."), # Фазовый сдвиг воспроизводится без изменения временной сетки.
|
|
("harmonic_1_2", "Основная частота и вторая гармоника", "CH1 содержит один период, CH2 — два периода за общий кадр."), # Целое отношение частот замыкает оба сигнала на границе таблицы.
|
|
("beats_5_6", "Биения · 5:6", "Пять и шесть колебаний за кадр; сложение каналов показывает биения."), # Разность частот равна частоте повторения общего кадра.
|
|
("triangle_sine", "Треугольник и синус", "Два периода треугольника и синуса для сравнения формы и гармоник."), # Обе формы имеют одинаковые амплитуду и смещение.
|
|
("square_quadrature", "Два меандра · 90°", "Квадратурные прямоугольные сигналы с заполнением 50 %."), # Смещение на четверть периода удобно для проверки квадратурного счётчика.
|
|
("pwm_25_75", "Импульсы · заполнение 25 / 75 %", "Два синхронных импульсных сигнала с разной длительностью высокого уровня."), # Общий старт периода позволяет сравнивать заполнение на осциллографе.
|
|
("amplitude_modulation", "АМ-сигнал и огибающая", "CH1 — несущая с восемью колебаниями, CH2 — её медленная огибающая."), # Глубина модуляции не выводит сигнал за диапазон ЦАП.
|
|
("chirp_iq", "ЛЧМ · квадратура I/Q", "Частота растёт от 2 до 14 колебаний на кадр; второй канал сдвинут на 90°."), # Интеграл линейной частоты даёт восемь полных оборотов фазы за кадр.
|
|
("ring_down", "Затухающие колебания · два канала", "Восемь квадратурных колебаний с экспоненциальным затуханием; пакет повторяется."), # Переход через границу кадра является началом следующего пакета.
|
|
) # Стабильные ключи совпадают с именами готовых CSV.
|
|
XY_EXAMPLES = ( # Контуры рассчитаны для одинакового масштаба X и Y и входной связи DC.
|
|
("smiley", "Улыбка", "Лицо, глаза и улыбка; быстрые переходы между четырьмя контурами."), # Первый пример сохраняет прежний тест генератора.
|
|
("circle", "Окружность", "Один непрерывный круг — проверка квадратуры и масштаба каналов."), # Простейшая замкнутая траектория без переходов между деталями.
|
|
("heart", "Сердце", "Параметрическое сердце с плавным замкнутым контуром."), # Классическая сумма гармоник формирует узнаваемую фигуру.
|
|
("star", "Пятиконечная звезда", "Непрерывный контур звезды с пятью внешними и пятью внутренними вершинами."), # Луч проходит все стороны в порядке обхода контура.
|
|
("rose", "Лазерный цветок · 5 лепестков", "Полярная роза с пятью лепестками и общим центром."), # Повторяющиеся дуги напоминают узоры векторного лазерного шоу.
|
|
("lissajous", "Лиссажу · 3:2", "Замкнутая сетка из синусоид с отношением частот 3:2."), # Целые множители обеспечивают непрерывное периодическое замыкание.
|
|
("spirograph", "Спирограф · орбиты", "Гипотрохоида с восемью лепестками и переплетающимися орбитами."), # Разность основной и обратной гармоник создаёт тонкий симметричный узор.
|
|
("butterfly", "Бабочка", "Аналитическая кривая бабочки: симметричные крылья и волнистый край."), # Несколько оборотов параметра прорисовывают все детали замкнутой фигуры.
|
|
("saturn", "Сатурн", "Круглая планета и наклонное эллиптическое кольцо."), # Два контура сохраняются отдельно, чтобы предпросмотр показывал реальный переход.
|
|
("laser_tunnel", "Лазерный туннель", "Внешняя рамка неподвижна, внутренние скручиваются и раскручиваются в замкнутом цикле."), # Движение меняет относительные углы рамок, сохраняя центр и размер внешнего контура.
|
|
) # Ровно десять XY-примеров доступны в интерфейсе и на диске.
|
|
|
|
@dataclass(frozen=True) # Подготовленный кадр нельзя незаметно изменить после его выбора или экспорта.
|
|
class ExampleData: # Один результат содержит всё необходимое для просмотра и подготовки обоих выходов.
|
|
key: str # Постоянный идентификатор используется в CSV и каталоге примеров.
|
|
kind: str # Значение dual или xy определяет временной либо геометрический предпросмотр.
|
|
title: str # Русское название отображается в окне выбора и документации.
|
|
description: str # Описание объясняет форму, отношение частот и особенности обхода.
|
|
rate: int # Общая частота обоих каналов измеряется в парах отсчётов в секунду.
|
|
samples: tuple # Каждая строка содержит время в миллисекундах, X/CH1 и Y/CH2 в вольтах.
|
|
strokes: tuple # Контуры XY сохраняют границы деталей для честного отображения переходов луча.
|
|
frames: tuple = () # Анимация хранит отдельные позы, тогда как аппаратная таблица последовательно содержит их все.
|
|
animation: str = "none" # Название фактического движения связывает предпросмотр, аппаратные кадры и метаданные экспортированного примера.
|
|
|
|
def _curve(function, count): # Аналитическая траектория преобразуется в фиксированное число отсчётов одного периода.
|
|
return tuple(function(2 * math.pi * index / count) for index in range(count)) # Конечная точка периода не дублируется, замыкание происходит при повторении DMA-таблицы.
|
|
|
|
def _polygon(vertices, count): # Равномерное движение вдоль ломаной не зависит от разной длины её сторон.
|
|
lengths = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(vertices, vertices[1:])] # Длины сторон измеряются в нормированных координатах фигуры.
|
|
total, segment, origin = sum(lengths), 0, 0.0 # Суммарный путь и начало текущей стороны задают положение движущегося луча.
|
|
result = [] # Координаты сохраняются в порядке обхода, без скачков между соседними сторонами.
|
|
for index in range(count): # Аппаратный кадр получает ровно запрошенное число координат.
|
|
distance = total * index / count # Равный шаг по длине пути удерживает примерно одинаковую яркость прямых участков.
|
|
while segment < len(lengths) - 1 and distance > origin + lengths[segment]: # Переходим к следующей стороне после достижения её начала.
|
|
origin += lengths[segment] # Пройденная длина предыдущей стороны исключается из локальной координаты.
|
|
segment += 1 # Последующая интерполяция использует следующую пару вершин.
|
|
fraction = (distance - origin) / lengths[segment] # Доля текущей стороны лежит между её началом и концом.
|
|
a, b = vertices[segment:segment + 2] # Две соседние вершины определяют единственный прямой участок.
|
|
result.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная интерполяция не добавляет закругления к углам геометрической фигуры.
|
|
return tuple(result) # Готовая последовательность подходит для периодического воспроизведения и предпросмотра.
|
|
|
|
def smiley_strokes(vref): # Прежний рисунок сохраняет четыре контура и ровно 1000 синхронных отсчётов.
|
|
if not math.isfinite(vref) or not 0 < vref <= 3.6: # Предел опорного напряжения совпадает с контрактом 12-битного ЦАП.
|
|
raise ValueError("Vref должен быть в диапазоне 0…3,6 В") # Недопустимая шкала отвергается до расчёта таблицы.
|
|
contours = ((0, 0, .85, 0, 2 * math.pi, 500), (-.27, .25, .055, 0, 2 * math.pi, 80), (.27, .25, .055, 0, 2 * math.pi, 80), (0, .10, .50, 7 * math.pi / 6, 11 * math.pi / 6, 340)) # Лицо, два глаза и нижняя дуга улыбки сохраняют прежние размеры и порядок.
|
|
return tuple(tuple(((1 + cx + radius * math.cos(start + (stop - start) * i / (count - 1))) * vref / 2, (1 + cy + radius * math.sin(start + (stop - start) * i / (count - 1))) * vref / 2) for i in range(count)) for cx, cy, radius, start, stop, count in contours) # Обе конечные точки дуг включены, а отступ лица от шин питания составляет 7,5 % Vref.
|
|
|
|
def _xy_strokes(key): # Нормированные траектории отделены от электрического масштаба и поворота рисунка.
|
|
if key == "smiley": # Совместимость старого теста важна для экспорта и настройки осциллографа.
|
|
return tuple(tuple((x - 1, y - 1) for x, y in stroke) for stroke in smiley_strokes(2.0)) # При Vref 2 В вычитание единицы восстанавливает исходные координаты вокруг нуля.
|
|
functions = { # Каждый аналитический контур укладывается в один непрерывный аппаратный период.
|
|
"circle": lambda a: (math.cos(a), math.sin(a)), # Одинаковые амплитуды синуса и косинуса дают точный круг.
|
|
"heart": lambda a: (16 * math.sin(a) ** 3, 13 * math.cos(a) - 5 * math.cos(2 * a) - 2 * math.cos(3 * a) - math.cos(4 * a)), # Классическая параметризация сердца использует безразмерные коэффициенты гармоник.
|
|
"rose": lambda a: (math.cos(5 * a) * math.cos(a), math.cos(5 * a) * math.sin(a)), # Полярный радиус cos(5a) создаёт пять симметричных лепестков.
|
|
"lissajous": lambda a: (math.sin(3 * a + math.pi / 2), math.sin(2 * a)), # Частоты 3 и 2 колебания за период формируют замкнутую фигуру Лиссажу.
|
|
"spirograph": lambda a: (.65 * math.cos(a) + .35 * math.cos(7 * a), .65 * math.sin(a) - .35 * math.sin(7 * a)), # Противоположное вращение седьмой гармоники создаёт восемь радиальных лепестков.
|
|
"butterfly": lambda a: (math.sin(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5), math.cos(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5)), # Полный период аналитической бабочки составляет 24π по параметру a.
|
|
} # Геометрическое масштабирование ниже оставляет запас до нуля и Vref при любом повороте.
|
|
if key in functions: # Большинство фигур формируются одним аналитическим контуром.
|
|
function = functions[key] # Выбор по ключу не зависит от перевода отображаемого названия.
|
|
strokes = (_curve((lambda a: function(12 * a)) if key == "butterfly" else function, SAMPLE_COUNT),) # Двенадцать оборотов замыкают полный период бабочки; остальные кривые обходятся за 2π.
|
|
elif key == "star": # Чередование двух радиусов формирует выпуклые и внутренние вершины звезды.
|
|
vertices = tuple(((1 if i % 2 == 0 else .42) * math.cos(math.pi / 2 + i * math.pi / 5), (1 if i % 2 == 0 else .42) * math.sin(math.pi / 2 + i * math.pi / 5)) for i in range(10)) # Внешние вершины смотрят вверх; внутренний радиус равен 42 % внешнего.
|
|
strokes = (_polygon(vertices + vertices[:1], SAMPLE_COUNT),) # Дополнительная первая вершина задаёт последнюю замыкающую сторону.
|
|
elif key == "saturn": # Планета и кольцо требуют двух отдельных обходов.
|
|
strokes = (_curve(lambda a: (.54 * math.cos(a), .54 * math.sin(a)), 500), _curve(lambda a: (math.cos(a), .28 * math.sin(a) + .30 * math.cos(a)), 500)) # Равные бюджеты отсчётов дают круг и наклонное эллиптическое кольцо.
|
|
elif key == "laser_tunnel": # Четыре вложенных рамки создают перспективную композицию.
|
|
strokes = tuple(_polygon(tuple((radius * math.cos(math.pi / 4 + i * math.pi / 2 + turn), radius * math.sin(math.pi / 4 + i * math.pi / 2 + turn)) for i in range(5)), 250) for radius, turn in ((1, 0), (.72, .12), (.45, .24), (.20, .36))) # По 250 отсчётов на рамку сохраняют общий размер 1000 пар и постепенный поворот вложенных квадратов.
|
|
else: # Неизвестный ключ не должен незаметно подменяться случайной фигурой.
|
|
raise ValueError("Неизвестный XY-пример: " + key) # Ошибка данных каталога выявляется до подготовки аппаратного задания.
|
|
points = [point for stroke in strokes for point in stroke] # Общий центр и масштаб вычисляются по всем деталям рисунка.
|
|
cx = (min(x for x, _ in points) + max(x for x, _ in points)) / 2 # Центр ограничивающего прямоугольника убирает постоянное смещение сложной фигуры.
|
|
cy = (min(y for _, y in points) + max(y for _, y in points)) / 2 # Вертикальное центрирование сохраняет запас до обеих шин ЦАП.
|
|
radius = max(math.hypot(x - cx, y - cy) for x, y in points) # Ограничение радиуса сохраняет диапазон напряжений при любом угле поворота.
|
|
return tuple(tuple((.85 * (x - cx) / radius, .85 * (y - cy) / radius) for x, y in stroke) for stroke in strokes) # Радиус 0,85 оставляет минимум 7,5 % Vref с каждой стороны при полном масштабе.
|
|
|
|
def _dual_point(key, t): # Значение t задаёт долю общего повторяющегося периода двух каналов.
|
|
a = 2 * math.pi * t # Основная фаза за один аппаратный кадр проходит полный оборот.
|
|
functions = { # Нормированные амплитуды всех форм не превышают единицу по модулю.
|
|
"quadrature": lambda: (math.sin(a), math.cos(a)), # Квадратурная пара имеет одинаковые частоты и сдвиг 90°.
|
|
"phase_120": lambda: (math.sin(a), math.sin(a + 2 * math.pi / 3)), # Сдвиг 2π/3 соответствует 120 электрическим градусам.
|
|
"harmonic_1_2": lambda: (math.sin(a), math.sin(2 * a)), # Вторая координата содержит вторую гармонику первой.
|
|
"beats_5_6": lambda: (math.sin(5 * a), math.sin(6 * a)), # Разность частот составляет одно колебание за общий кадр.
|
|
"triangle_sine": lambda: (1 - 4 * abs((2 * t) % 1 - .5), math.sin(2 * a)), # Треугольник и синус совершают по два колебания за кадр.
|
|
"square_quadrature": lambda: (1 if t < .5 else -1, 1 if (t + .25) % 1 < .5 else -1), # Два меандра заполнения 50 % смещены на четверть периода.
|
|
"pwm_25_75": lambda: (1 if t < .25 else -1, 1 if t < .75 else -1), # Длительности высоких уровней составляют 25 и 75 % периода.
|
|
"amplitude_modulation": lambda: ((.6 + .4 * math.sin(a)) * math.sin(8 * a), .6 + .4 * math.sin(a)), # Второй канал совпадает с верхней огибающей восьмипериодной несущей относительно общего смещения Vref/2.
|
|
"chirp_iq": lambda: (math.sin(2 * math.pi * (2 * t + 6 * t * t)), math.cos(2 * math.pi * (2 * t + 6 * t * t))), # Производная фазы задаёт линейный рост частоты от 2 до 14 циклов на кадр.
|
|
"ring_down": lambda: (math.exp(-4 * t) * math.sin(8 * a), math.exp(-4 * t) * math.cos(8 * a)), # За кадр амплитуда уменьшается в e⁴ раз, после чего начинается следующий пакет.
|
|
} # Каждый ключ каталога имеет самостоятельную аналитическую форму пары.
|
|
return tuple(.85 * value for value in functions[key]()) # Ограничение амплитуды 85 % полудиапазона оставляет запас 7,5 % Vref до шин питания.
|
|
|
|
def _cat_bezier(start, first, second, end, steps): # Кубическая кривая возвращает только новые вершины; общий конец соседних дуг не дублируется.
|
|
return tuple(((1-t)**3*start[0]+3*(1-t)**2*t*first[0]+3*(1-t)*t*t*second[0]+t**3*end[0], (1-t)**3*start[1]+3*(1-t)**2*t*first[1]+3*(1-t)*t*t*second[1]+t**3*end[1]) for t in (i/steps for i in range(1,steps+1))) # Формула Бернштейна сохраняет гладкие щёки, спину и лапы внутри выпуклой оболочки контрольных точек.
|
|
|
|
def cat_strokes(phase): # Одна поза содержит 125 точек плавного силуэта; 32 позы сохраняют прежнюю аппаратную таблицу из 4000 пар.
|
|
angle=2*math.pi*phase; bob=.018*math.cos(2*angle) # Один замкнутый шаговый цикл задаёт плавное движение диагональных лап и небольшое покачивание корпуса.
|
|
body=((.26,.38),) # Начинаем у загривка: плечи, спина, круп и живот образуют органичный контур вместо правильного овала.
|
|
for first,second,end,steps in (((.08,.52),(-.30,.48),(-.44,.40),4),((-.60,.39),(-.67,.25),(-.60,.13),4),((-.53,-.06),(-.40,-.13),(-.20,-.06),4),((.12,-.11),(.28,.02),(.27,.20),5),((.31,.30),(.29,.35),(.26,.38),3)): # Пять дуг распределяют 20 новых точек между спиной, крупом, животом, грудью и шеей.
|
|
body+=_cat_bezier(body[-1],first,second,end,steps) # Касательные на соседних дугах меняются плавно и замыкают туловище за 21 точку.
|
|
head=((.20,.43),)+_cat_bezier((.20,.43),(.12,.55),(.17,.69),(.26,.72),4)+((.26,.90),(.39,.76)) # Левая щека округлена, а две вершины сохраняют естественный острый кончик левого уха.
|
|
head+=_cat_bezier(head[-1],(.45,.80),(.55,.80),(.61,.75),4)+((.78,.87),(.75,.63)) # Плавный лоб соединяет два уха; правое ухо имеет отдельные кончик и наружное основание.
|
|
head+=_cat_bezier(head[-1],(.89,.52),(.89,.36),(.74,.29),6)+_cat_bezier((.74,.29),(.63,.18),(.39,.17),(.26,.30),6)+_cat_bezier((.26,.30),(.21,.34),(.18,.38),(.20,.43),4) # Щёки и подбородок замыкаются округлой мордочкой; всего 29 точек вместо прежнего угловатого многоугольника.
|
|
tail=((-.54,.29),)+_cat_bezier((-.54,.29),(-.77,.30),(-.99,.61),(-.84,.83),5)+_cat_bezier((-.84,.83),(-.74,1.00),(-.48,.98),(-.56,.78),6) # Две плавные дуги образуют приподнятый хвост с округлым загнутым кончиком, всего 12 координат.
|
|
tail=tuple((x+.05*math.sin(angle+.8*i/11)*(i/11)**2,y+bob+.02*math.cos(angle+i/11)*i/11) for i,(x,y) in enumerate(tail)) # Покачивание усиливается к концу хвоста; основание следует за корпусом без разрывов.
|
|
legs=[] # Каждая лапа сохраняет собственный контур и фазу, чтобы передняя и дальняя пары не слипались.
|
|
for root,offset in ((-.45,0),(-.28,math.pi),(.07,math.pi),(.20,0)): # Диагональные пары чередуют опору и перенос, сохраняя узнаваемую походку.
|
|
stride=.14*math.sin(angle+offset); foot_y=-.41+.10*max(0,math.cos(angle+offset))**2 # Во время переноса лапа идёт вперёд и поднимается по гладкой дуге; у земли вертикальная скорость равна нулю.
|
|
start=(root,.07+bob); ankle=(root+stride,foot_y+.015) # Корень спрятан внутри туловища, голень заканчивается немного выше подушечки лапы.
|
|
legs.append((start,)+_cat_bezier(start,(root+stride*.18,-.04),(root+stride*.25,foot_y+.09),ankle,4)+((ankle[0]+.045,foot_y-.015),(ankle[0]+.09,foot_y+.005))) # Семь точек сглаживают колено/голень и добавляют округлый носок вместо прямоугольного окончания.
|
|
whiskers=tuple(((.62 if side>0 else .49,.41+bob),(x,y+bob)) for side,x,y in ((1,.97,.54),(1,.99,.41),(1,.96,.29),(-1,.24,.54),(-1,.22,.41),(-1,.25,.29))) # Шесть независимых усов по две точки остаются хорошо различимыми по сторонам мордочки.
|
|
eyes=tuple(tuple((cx+.045*math.cos(2*math.pi*i/7),.54+bob+.035*math.sin(2*math.pi*i/7)) for i in range(8)) for cx in (.40,.66)) # Два небольших замкнутых овальных глаза получают по восемь точек вместо двух коротких прямых.
|
|
nose=(((.50,.43+bob),(.61,.43+bob),(.555,.38+bob),(.50,.43+bob)),) # Маленький треугольный нос занимает четыре координаты между глазами и усами.
|
|
mouth=(((.48,.35+bob),(.555,.30+bob),(.63,.35+bob)),) # Короткая улыбка делает мордочку узнаваемой, не увеличивая аппаратный бюджет фигуры.
|
|
strokes=(tuple((x,y+bob) for x,y in body),tuple((x,y+bob) for x,y in head),tail,*legs,*whiskers,*eyes,*nose,*mouth) # Сохраняем порядок деталей: тело/голова/хвост, четыре лапы, шесть усов, глаза, нос и рот.
|
|
return tuple(tuple((.72*x,.72*y) for x,y in stroke) for stroke in strokes) # Общий масштаб 0,72 удерживает новый округлый контур и хвост в допустимом диапазоне при любом повороте XY.
|
|
|
|
|
|
def boat_strokes(phase): # Один кадр содержит 30 неподвижных точек кораблика и 50 точек двух движущихся рядов волн.
|
|
hull = ((-.56, -.05), (-.20, -.05), (.20, -.05), (.58, -.05), (.42, -.26), (0, -.29), (-.36, -.26), (-.56, -.05)) # Восемь вершин замыкают корпус с поднятыми бортами и выделенным днищем без зависимости от фазы волн.
|
|
mast = ((0, -.05), (0, .72)) # Мачта закреплена на палубе и остаётся вертикальной во всех кадрах сцены.
|
|
main_sail = ((-.035, .65), (-.035, .34), (-.035, .03), (-.47, .03), (-.25, .34), (-.035, .65)) # Шесть координат левого треугольного паруса сохраняют его полный замкнутый контур.
|
|
fore_sail = ((.035, .55), (.25, .30), (.47, .03), (.25, .03), (.035, .03), (.035, .55)) # Шесть координат переднего паруса создают второй узнаваемый треугольник без поворота или качания.
|
|
pennant = ((0, .72), (.21, .65), (0, .58), (0, .72)) # Четыре точки замыкают неподвижный флажок на вершине мачты.
|
|
stripe = ((-.43, -.13), (-.15, -.16), (.20, -.16), (.46, -.13)) # Полоса на борту подчёркивает форму корпуса и также не зависит от движения воды.
|
|
upper_wave, lower_wave = [], [] # Два самостоятельных контура воды движутся с одной скоростью и имеют разные высоты и фазы.
|
|
for index in range(25): # По 25 точек на ряд дают 50 координат воды и общий бюджет ровно 80 пар на кадр.
|
|
x = -.85 + 1.70 * index / 24 # Фиксированное горизонтальное окно содержит обе границы воды и два полных периода волны.
|
|
angle = 2 * math.pi * (2 * index / 24 - phase) # Убывание фазы при фиксированном X перемещает гребни вправо, а полный цикл возвращает исходную воду.
|
|
upper_wave.append((x, -.36 + .05 * math.sin(angle) + .012 * math.sin(2 * angle))) # Небольшая вторая гармоника делает гребни выразительнее; верхняя вода остаётся ниже неподвижного днища.
|
|
lower_wave.append((x, -.55 + .04 * math.sin(angle + .8))) # Нижняя волна сдвинута на 0,8 радиана и создаёт глубину воды без изменения положения кораблика.
|
|
return tuple(tuple((.78 * x, .78 * y) for x, y in stroke) for stroke in (hull, mast, main_sail, fore_sail, pennant, stripe, upper_wave, lower_wave)) # Общий коэффициент 0,78 удерживает даже крайние точки воды в допустимом радиусе при любом пользовательском повороте сцены.
|
|
|
|
def _resample_strokes(strokes, count): # Аппаратный лимит делится между позами без удаления отдельных деталей фигуры.
|
|
total = sum(map(len, strokes)) # Доли исходных контуров определяют бюджет точек для каждой видимой детали.
|
|
budgets = [max(2, round(count * len(stroke) / total)) for stroke in strokes] # Минимум две точки сохраняет даже короткий отдельный контур.
|
|
largest = max(range(len(strokes)), key=lambda index: len(strokes[index])) # Коррекция округления применяется к самому длинному контуру с достаточным запасом точек.
|
|
budgets[largest] += count - sum(budgets) # Каждый геометрический кадр получает ровно согласованное число пар.
|
|
result = [] # Контуры остаются отдельными и сохраняют прежний порядок аппаратного обхода.
|
|
for stroke, budget in zip(strokes, budgets): # Уменьшаем только плотность точек внутри одной детали, не соединяя разные контуры.
|
|
closed = stroke[0] == stroke[-1] # Явно замкнутая кривая должна сохранить совпадение первой и последней координат.
|
|
samples = [] # Новые опорные точки распределяются вдоль исходного параметрического порядка.
|
|
for index in range(budget): # Бюджет фиксирует одинаковую длительность всех поз анимации.
|
|
position = index * (len(stroke) - 1) / (budget - 1) if closed else index * len(stroke) / budget # Замкнутые контуры включают оба конца, а периодические без дубликата учитывают последний переход к началу.
|
|
source = int(position) # Целая часть выбирает исходный линейный участок фигуры.
|
|
a, b = stroke[source], stroke[(source + 1) % len(stroke)] # Циклический сосед сохраняет замыкание параметрических контуров.
|
|
fraction = position - source # Дробная часть задаёт положение между двумя исходными вершинами.
|
|
samples.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная выборка не создаёт выход за диапазон исходной геометрии.
|
|
result.append(tuple(samples)) # Границы отдельных деталей сохраняются для предпросмотра и аппаратной пары.
|
|
return tuple(result) # Один компактный кадр пригоден для многократных геометрических преобразований.
|
|
|
|
def _square_stroke(radius, angle): # Компактная рамка сохраняет все четыре точных угла и полное замыкание при бюджете 20 пар.
|
|
vertices = tuple((radius * math.cos(angle + index * math.pi / 2), radius * math.sin(angle + index * math.pi / 2)) for index in range(4)) # Четыре вершины лежат на окружности заданного нормированного радиуса с шагом 90 градусов.
|
|
points = [] # Отдельные стороны включают свои конечные вершины до перехода к следующему контуру.
|
|
for side in range(4): # Каждая сторона получает одинаковую длительность аппаратного обхода.
|
|
a, b = vertices[side], vertices[(side + 1) % 4] # Последняя сторона возвращается точно к исходной вершине квадрата.
|
|
for sample in range(5): # Пять точек на сторону, включая оба конца, дают ровно 20 пар на рамку.
|
|
fraction = sample / 4 # Четыре равных интервала покрывают всю сторону, сохраняя последние 20 % контура перед переходом к другой детали.
|
|
points.append(b if sample == 4 else (a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Явная конечная вершина замыкает квадрат без разрыва или потери угла из-за округления.
|
|
return tuple(points) # Первая и последняя координаты совпадают, поэтому яркий контур целиком виден в каждой позе.
|
|
|
|
def _figure_frames(key, motion): # Создаёт замкнутый цикл изменения самой геометрии и общий бюджет не более 4000 пар.
|
|
if key == "laser_tunnel" and motion == "twist": # Для туннеля важны неподвижная внешняя рамка и разные углы вложенных квадратов.
|
|
frames = [] # Пятьдесят поз дают плавное скручивание и обратный ход за один аппаратный период.
|
|
for index in range(50): # По 80 точек на позу сохраняют полный объём 4000 пар.
|
|
twist = 1.8 * (1 - math.cos(2 * math.pi * index / 50)) / 2 # Угол от 0 до 1,8 радиана и обратно меняется плавно, без разрыва скорости на границе периода.
|
|
frames.append(tuple(_square_stroke(radius, math.pi / 4 + twist * depth / 3) for depth, radius in enumerate((.85, .612, .3825, .17)))) # Глубина 0 фиксирует внешнюю рамку; по 20 пар сохраняют все углы и полностью замкнутые стороны каждого вложенного квадрата.
|
|
return tuple(frames) # Одинаковые контуры и индексы вершин позволяют проверить обратимость геометрического движения.
|
|
count, frame_count = (200, 20) if key == "butterfly" else (100, 40) # Сложной бабочке оставляем 200 точек на позу; остальным фигурам достаточно 100 при 40 кадрах/с.
|
|
original = _resample_strokes(_xy_strokes(key), count) # Исходная форма подготавливается один раз, а не заново на каждом тике GUI.
|
|
frames = [] # Весь цикл хранится в неизменяемом задании и попадает в CSV целиком.
|
|
for index in range(frame_count): # Последняя поза не дублирует первую; возврат замыкается следующим аппаратным периодом.
|
|
phase = 2 * math.pi * index / frame_count # Полный оборот фазы соответствует одному геометрическому циклу.
|
|
deformation = (1 - math.cos(phase)) / 2 # Плавный ход 0…1…0 обеспечивает скручивание и последующее раскручивание.
|
|
strokes = [] # Каждая поза сохраняет исходный набор деталей и их порядок.
|
|
for stroke in original: # Деформация применяется ко всем координатам фигуры вокруг общего центра.
|
|
points = [] # Точки одной детали преобразуются независимо от частоты обхода контура.
|
|
for x, y in stroke: # Нормированные координаты находятся внутри радиуса 0,85 до электрического масштабирования.
|
|
angle = phase if motion == "rotate" else 2.4 * deformation * max(0, 1 - math.hypot(x, y) / .85) if motion == "twist" else 0 # Скручивание усиливается к центру, а обычное вращение сохраняет относительную форму.
|
|
scale_x = 1 - .35 * deformation if motion in ("pulse", "breathe") else 1 # Пульсация плавно уменьшает ширину фигуры, сохраняя запас до шин ЦАП.
|
|
scale_y = 1 - (.15 if motion == "breathe" else .35) * deformation if motion in ("pulse", "breathe") else 1 # Разные масштабы дыхания превращают окружность в эллипс, чтобы движение было видно у симметричной фигуры.
|
|
points.append((scale_x * (x * math.cos(angle) - y * math.sin(angle)), scale_y * (x * math.sin(angle) + y * math.cos(angle)))) # Поворот и сжатие сохраняют допустимый радиус при любой фазе цикла.
|
|
strokes.append(tuple(points)) # Полный контур одной позы виден одновременно, без бегущего светового хвоста.
|
|
frames.append(tuple(strokes)) # Все детали текущей позы сохраняются перед переходом к следующей фазе движения.
|
|
return tuple(frames) # Размер каждого цикла равен 4000 пар и укладывается в аппаратный предел 4096.
|
|
|
|
def generate_example(kind, key, vref=3.3, rate=None, amplitude=1.0, rotation=0.0, animation="none"): # Параметр анимации выбирает полный аппаратный цикл движения фигуры; статический API остаётся совместимым с прежними заданиями.
|
|
if kind not in ("dual", "xy"): # Список режимов ограничен двухканальными временными сигналами и геометрией XY.
|
|
raise ValueError("Неизвестный тип примера") # Ошибка вызывающего кода не должна выбирать другой каталог.
|
|
catalogue = DUAL_EXAMPLES if kind == "dual" else XY_EXAMPLES + XY_EXTRA_EXAMPLES # Кот и кораблик добавляются после десяти основных фигур без подмены прежних примеров.
|
|
metadata = next((entry for entry in catalogue if entry[0] == key), None) # Постоянный ключ определяет русское имя и описание выбранного примера.
|
|
if metadata is None: # Неизвестные или перепутанные ключи явно отвергаются.
|
|
raise ValueError("Неизвестный пример: " + key) # Сообщение содержит конкретный отсутствующий идентификатор.
|
|
if animation not in dict(ANIMATION_MODES): # Явная проверка исключает незаметную подмену опечатанного режима статической картинкой.
|
|
raise ValueError("Неизвестная анимация фигуры: " + animation) # Ошибка выявляется до создания и загрузки частичной таблицы.
|
|
motion = ({"circle": "breathe", "heart": "pulse", "laser_tunnel": "twist", "butterfly": "pulse"}.get(key, "rotate") if animation == "auto" else animation) if kind == "xy" else "none" # Режим по фигуре выбирает заметное движение даже для вращательно-симметричной окружности.
|
|
motion = {"walking_cat": "walk", "boat_waves": "waves"}.get(key, motion) # Специальные сцены сохраняют заданное движение: у кота работают лапы и хвост, а у кораблика движется исключительно вода.
|
|
if rate is None: # Частота по умолчанию зависит от полного размера анимированного цикла.
|
|
rate = ANIMATION_SAMPLE_RATE if motion != "none" else SAMPLE_RATE # Любой полный цикл из 4000 пар длится одну секунду; статический контур сохраняет прежние 40 кГц.
|
|
if not math.isfinite(vref) or not 0 < vref <= 3.6 or not math.isfinite(amplitude) or not 0 < amplitude <= 1 or not math.isfinite(rotation): # Положительный масштаб до 100 % и Vref до 3,6 В сохраняют электрический диапазон обоих выходов.
|
|
raise ValueError("Проверьте Vref, масштаб 0…100 % и угол поворота") # Некорректные параметры не формируют частично пригодный кадр.
|
|
if type(rate) is not int or not 1 <= rate <= 1_000_000: # Частота ограничена тем же пределом 1 Мвыб/с, что основной генератор.
|
|
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц") # Аппаратный профиль дополнительно проверяется при переносе в редактор.
|
|
if key in ("walking_cat", "boat_waves"): # Специальная анимация строится напрямую из неизменных и движущихся деталей сцены.
|
|
frame_generator = cat_strokes if key == "walking_cat" else boat_strokes # Выбор функции не смешивает общий поворот фигуры с движением её отдельных элементов.
|
|
frame_count = 32 if key == "walking_cat" else 50 # Коту нужны 125 точек на плавный силуэт, кораблик сохраняет прежние 50 поз по 80 точек.
|
|
frames = tuple(frame_generator(index / frame_count) for index in range(frame_count)) # Оба специальных примера сохраняют ровно 4000 пар, одну секунду цикла и аппаратный предел 4096.
|
|
else: # Обычные геометрические фигуры продолжают использовать существующие деформации и статичный режим.
|
|
frames = _figure_frames(key, motion) if motion != "none" else () # Предыдущие контуры сохраняют свой бюджет и поведение без изменений.
|
|
normalized = tuple(stroke for frame in frames for stroke in frame) if frames else _xy_strokes(key) if kind == "xy" else (tuple(_dual_point(key, i / SAMPLE_COUNT) for i in range(SAMPLE_COUNT)),) # Аппаратный кадр кота последовательно содержит все позы, а обычные примеры сохраняют 1000 пар.
|
|
angle = math.radians(rotation if kind == "xy" else 0) # Поворот изменяет только геометрические фигуры и не смешивает диагностические временные сигналы.
|
|
cosine, sine = math.cos(angle), math.sin(angle) # Тригонометрия рассчитывается один раз для всего кадра, а не для каждого отсчёта.
|
|
strokes = tuple(tuple(((1 + amplitude * (x * cosine - y * sine)) * vref / 2, (1 + amplitude * (x * sine + y * cosine)) * vref / 2) for x, y in stroke) for stroke in normalized) # Однополярные выходы центрируются на Vref/2, а XY поворачивается вокруг этого центра.
|
|
samples = tuple((index * 1000 / rate, x, y) for index, (x, y) in enumerate(point for stroke in strokes for point in stroke)) # Обе координаты каждого индекса имеют строго одно время в миллисекундах.
|
|
frame_size = len(frames[0]) if frames else 0 # Любая геометрическая анимация сохраняет одинаковое число контуров и пар во всех позах.
|
|
display_frames = tuple(strokes[index:index + frame_size] for index in range(0, len(strokes), frame_size)) if frames else () # Преобразованные позы сохраняют точные напряжения той же аппаратной последовательности.
|
|
return ExampleData(key, kind, metadata[1], metadata[2], rate, samples, display_frames[0] if frames else strokes, display_frames, motion) # Предпросмотр, экспорт и аппаратная пара получают одно движение и все его кадры.
|