Особенности и типичные ошибки¶
Здесь собрано неочевидное поведение manim, Python и внутренностей AnimaGeo, найденное при разработке и тестировании. Держите это в голове, когда расширяете библиотеку.
Имена решений пересечения при импорте¶
Начиная с 1.7.16, выходы GGB-команды Intersect существуют и сохраняют
видимость и оформление даже при неопределённом решении (elem.data is None).
При появлении решения используется тот же элемент и пересчитываются его
зависимости. Исчезнувшие решения очищаются, а не сохраняют прежние координаты.
Для пересечений окружностей и коник сохранённые координаты помогают сопоставить
вычисленные решения с именами и явными индексами 1/2. Однородные координаты
делятся на z. Они выбирают ветвь, но не замораживают геометрию: при движении
входов точки продолжают пересчитываться. Это важно и для статичной картинки:
смена знака нормали геометрически той же прямой меняет порядок корней.
Пересечение окружности с отрезком или лучом сначала может заполнить первый
выход единственным решением на пути. После появления двух решений сохраняются
их места: выход за пределы пути делает соответствующую точку неопределённой,
не переименовывая оставшуюся. Свежий apply_keyframes_at учитывает предыдущие
ключевые кадры перед выборкой такого пересечения. Прямые функции пересечения
и команды Python сохраняют прежний порядок корней; привязка относится к GGB.
Полный непрерывный поиск ближайших решений GeoGebra для произвольных коник
не реализован. Стражи: tests/test_ggb_intersection_binding.py.
manim¶
Mobject.set_default накапливает цепочку partialmethod — не вызывайте его на горячем пути¶
cls.set_default(**kwargs) в manim выполняет
cls.__init__ = partialmethod(cls.__init__, **kwargs). Чтение cls.__init__
у класса проходит через дескриптор partialmethod.__get__ и возвращает
скомпилированную функцию _method, а не сам объект partialmethod — поэтому
встроенное схлопывание вложенных partialmethod никогда не срабатывает и каждый
вызов добавляет ещё один слой обёртки. Вызов на каждом рендере (как раньше
делал setStyle) наращивает глубину цепочки линейно; после примерно 1000
вызовов в одном долгоживущем процессе любое создание MathTex, Tex или
Text падает с RecursionError. Симптомы выглядят не связанными с причиной:
точки и подписи молча исчезают (поэлементное восстановление в _render_*),
autoPlaceLabels перестаёт работать (измерение bbox идёт через Tex), ломаются
числовые подписи (DecimalNumber в manim 0.20 строит глифы через
mob_class=MathTex).
Правила:
- Никогда не вызывайте
set_defaultв коде, который выполняется на каждом рендере. ИзsetStyleон убран; цвет подписи передаётся явно (col_labelв_build_render_ctx→create_label). - Если глобальное значение по умолчанию действительно нужно (скрипты,
примеры) — задайте его один раз на процесс или сделайте вызов
идемпотентным: сначала
cls.set_default()(сброс к_original__init__), затемcls.set_default(color=...). set_default()без аргументов полностью восстанавливает исходный__init__— на этом держатся тестовые фикстуры (tests/test_mobject_default_leak.py).Tex, созданный с явнымcolor=, может вернутьNoneизget_fill_color()верхнего уровня — проверяйте реальный цвет глифов черезfamily_members_with_points().
tex_template работает только в конструкторе — .set(tex_template=…) ничего не делает¶
Tex.__init__ компилирует LaTeX сразу: если tex_template не передан
аргументом, берётся config["tex_template"]. Присваивание после конструктора —
Tex(s).set(font_size=…, tex_template=RusTex) — лишь кладёт атрибут на уже
скомпилированный объект и никак не влияет на рендер. Раньше подписи в
create_label строились именно так: выглядело, будто шаблон передан, а
компиляция шла под штатным шаблоном manim без кириллицы, и любая кириллическая
подпись роняла весь элемент (CreateMObject failed → пропадали и маркер, и
подпись).
Правила:
tex_template— только аргумент конструктора.- Шаблон с поддержкой кириллицы устанавливается глобальным значением по
умолчанию один раз при инициализации сцены:
ui.install_cyrillic_tex_template()(вызывается изAnimaGeoScene.__init__). Это НЕMobject.set_default— это обычное идемпотентное присваивание вconfig.tex_template, поэтому цепочка partialmethod не накапливается. Шаблон, установленный вызывающей стороной (не штатный), не трогается. - Неудачная компиляция подписи не должна ронять элемент:
ui._compile_label_texдеградирует по цепочке LaTeX → экранированный простой текст →None(элемент рисуется без подписи), а_measure_label_bboxв этом случае оценивает bbox приблизительно.
Кириллица в математическом режиме компилируется в пустоту¶
Это хуже ошибки: $Б$ под T2A компилируется успешно и не рисует ничего —
в математическом алфавите нет кириллических глифов. Симптомы: подпись $Б_1$
показывала одинокую «1», а из текста Отрезок $БВ$ равен молча пропадал кусок.
Воспроизводится и в manim (latex→dvisvgm), и в экспорте TikZ (pdflatex).
Правило: оборачивайте кириллические фрагменты внутри $…$ в текстовый
режим — это делает geo.lib_elements.textify_cyrillic ($Б$ →
$\text{Б}$; под нижним индексом ещё и в фигурных скобках:
$A_{\text{Б}}$, иначе _\text захватит только команду). Применяется в
correctedLabel, _render_text и в TikZ (TikzContext.label_text,
emit_text). Кириллица, уже набранная в текстовом режиме, не трогается — она
отрисовывается как есть. Экспорт JSXGraph не затронут: его подписи идут через
MathJax, а там кириллица в математическом режиме есть.
Рендер внешнего скрипта сцены через CLI manim¶
python3 -m manim ... может попасть в интерпретатор Python, где manim не
установлен. Надёжная команда для локального рендера отдельного скрипта сцены:
PYTHONPATH=/path/to/animageo manim scene.py MyScene -ql --format=png --media_dir /tmp/animageo_render
Если нужно запустить pytest или другие проверки тем же Python, в котором стоит manim (Homebrew на macOS):
Updater на анимируемом объекте никогда не видит промежуточных значений¶
Если add_updater(func) навешан на тот же ValueTracker, который
анимируется через .animate.set_value(), то внутри func вызов
mob.get_value() (где mob — параметр, переданный manim) вернёт только
начальное и конечное значения, но никогда промежуточные. Причина: во время
анимации manim создаёт копии начального и конечного состояний объекта и
вызывает updater на этих копиях, а не на интерполируемом оригинале.
Рабочие подходы:
-
Объект-часовой (используется в
play_keyframes): updater живёт на отдельном невидимомMobject, аValueTrackerчитается через замыкание: -
Замыкание вместо параметра (используется в
addUpdater): updater стоит на трекере, но читает захваченную переменную, а не параметрmob:
Не работает:
Polygon не поддерживает become()¶
В manim become() не работает корректно для Polygon (мерцание, сломанная
анимация). Поэтому updateGeoElements обрабатывает многоугольники через
remove + add, а не через become(). Это известное поведение manim, а не баг
AnimaGeo.
Пара remove + add не должна менять порядок слоёв: эффективный z_index в
manim дополнительно получает микросмещение по позиции элемента в конструкции.
Так порядок внутри одного уровня (Z_STROKE, Z_POINT и т. д.) сохраняется и
в статическом рендере, и во время анимации MP4.
Пунктир — свойство штриха: держите его на мобъекте, а не в пути¶
С 1.7.10 пунктирный штрих — тот же единственный VMobject, что и у сплошной
линии, с animageo.dash.DashPattern в приватном атрибуте _ag_dash (в MU,
фиксируется при сборке мобъекта). Применяет его тот, кто рисует штрих:
svg_parser (SVG/PDF/EPS) и DashCamera, видеокамера сцены. Три ловушки:
Mobject.__getattr__в manim сам создаёт именаget_*/set_*, поэтому шаблон не выставлен какmobject.get_dash(); используйте функции модуляanimageo.dash.get_dash(m)/set_dash(m, pattern).become()копирует точки и цвета, но не пользовательские атрибуты (и неcap_style);updateGeoElementsпереносит шаблон и колпачок в том же цикле по семейству, что иz_index. Любой новый путь черезbecome()должен делать так же, иначе переход «сплошная ↔ пунктир» не дойдёт до линии.- cairo (и SVG) начинают шаблон заново на каждом подпути. Кривая из множества
мелких кусков выглядит сплошной:
_render_implicitcurveштрихует сшитые полилинии (curve_sampling.stitch_segments), а не ячейки marching squares. Не используйте manim-овскиеDashedLine/DashedVMobject: они режут путь на мобъект на каждый штрих, и в экспорте получается россыпь<path>.
Размещение подписей¶
dynamic_angles=true сам по себе ничего не делает¶
Флаг overlay.label_placement.dynamic_angles=true помечает углы для
отслеживания биссектрисы, но кто-то должен реально вызывать
compute_angle_label_center на каждом кадре. Поддерживаются два способа это
включить:
play_keyframesприkeyframe_snapshots=true—on_frameзапускает пересчёт автоматически.scene.autoPlaceLabels(dynamic=True)— устанавливаетLabelTracker, иupdateVar(срабатывает при каждом изменении трекера внутриaddUpdater) переписывает смещения.
Если задан только dynamic_angles=true и ни одного из вариантов выше, углы
остаются статичными, как и раньше. Так задумано: опцию дёшево включить в
конфигурации, а цена покадровой работы — осознанный выбор точки интеграции.
Канонизация MC по умолчанию выключена¶
canonicalize_anchor=true переписывает подписи в label_anchor='MC' с
компенсирующим смещением. Это убирает скачки якоря при интерполяции между
ключевыми кадрами, но ломает снимочные тесты в test_loadggb_snapshot.py — в
них зафиксированы label_anchor='BC'/'ML'/… из старого решателя. Поэтому по
умолчанию false; включайте, только когда важна плавная динамика.
Кэш bbox для Tex — на уровне модуля, а не сцены¶
_bbox_cache в label_placement.py — общий на процесс словарь с ключом
(label_text, font_size). Когда между двумя сценами в одном процессе меняется
font_size в GeoStyle, кэш переиспользуется корректно (ключ включает
font_size). Но если вы вручную подменяете TeX-шаблон (RusTex) в памяти —
вызовите clear_bbox_cache(). play_keyframes делает это автоматически в
начале прохода со снимками.
overlay.angle_radius по умолчанию выключен¶
compute_effective_arc_size_px (общий для рендерера и размещения подписей)
применяет масштабирование (pivot_rad / angle) ** exp и ограничения только
при overlay.angle_radius.enabled=true. По умолчанию false — так
сохраняется побайтовый импорт GGB и снимочные тесты продолжают проходить.
Включайте осознанно: при enabled=true меняется видимый размер каждой дуги
угла в сцене (узкие углы становятся крупнее, широкие — мельче). Для точечного
отказа есть отключение на уровне элемента:
elem.style['auto_radius'] = False.
Ключ angle_gap_px убран¶
Ключ overlay.label_placement.angle_gap_px больше не читается. Вместо него
используйте два явных: angle_gap_arc_px — зазор «дуга → подпись» и
angle_gap_sides_px — зазор «стороны → bbox подписи».
Импорт GeoGebra¶
Point(Conic) обязан сохранять параметр из координат в XML¶
GeoGebra умеет создавать точку на кривой второго порядка командой Point(e),
где e — Conic (например, гипербола). Если импортёр не поддерживает
Point(Conic), такая точка остаётся с data=None, и все зависящие от неё
команды (Line, Intersect, Segment, Distance, CircumcircleArc,
Angle) ломаются каскадом.
Решение: команда point_K строит точку на окружности, эллипсе, гиперболе или
параболе в канонической параметризации, а парсер GGB вычисляет elem.tparam
из координат <element type="point">. Для гиперболы параметр хранится как
(branch, t), чтобы сохранить ветвь, выбранную в GeoGebra.
CircumcircleArc(A, M, B) выбирает дугу, проходящую через среднюю точку¶
Правило GeoGebra: CircumcircleArc(A, M, B) строит дугу окружности с концами
A и B, проходящую через M. Значит, относительно хорды AB выбранная
дуга должна лежать по ту же сторону от прямой AB, что и M (если только M
не лежит на самой прямой).
Типичная ошибка реализации: Arc хранит диапазон как развёрнутый интервал
[angle_start, angle_end], где angle_end может превышать 2π, а
проверяемая точка вычисляется через np.angle(...) в диапазоне [-π, π].
Эти значения нельзя сравнивать напрямую: дуги, пересекающие нулевой угол,
начинают ошибочно считаться не содержащими собственную среднюю точку. Перед
сравнением угол точки нужно поднять в тот же развёрнутый интервал.
Юникодные имена в командах GGB должны оставаться прямыми ссылками¶
GeoGebra свободно использует в качестве подписей имена вроде α и β. Если
проверять «простое имя» регулярным выражением по ASCII, команда
Intersect(β, k, 1) превратится в выражение через фантомную переменную
(_1 = β), и когда это разрешение не удастся, зависимые команды получат
None.
Правило: после нормализации имени системная проверка на простой идентификатор
Python/DSL — это str.isidentifier(). Юникодные подписи должны проходить
насквозь как обычные ссылки на существующие элементы.
В такой же конструкции могут встретиться Intersect(β, k, 1) и
Intersect(β, l, 1), где β — дуга, а k и l — лучи. Поэтому AnimaGeo
поддерживает не только Arc ∩ Line, но и индексированные пересечения
Arc ∩ Ray и Arc ∩ Segment.
Подписи, которые не являются идентификаторами, и знак градуса¶
GeoGebra принимает подписи, недопустимые в Python, например K°. Имя
нормализуется одинаково и в самом элементе, и во всех выражениях, где оно
встречается: каждый символ, который не может продолжать идентификатор,
заменяется на _ (K° → K_), ' — на _Prime, а к имени, начинающемуся с
цифры, добавляется префикс var_. Знак градуса после числа или числовой
переменной, которая не является такой подписью, означает угол:
Rotate(A, K°, O) с ползунком K поворачивает на K градусов.
Point(путь, t) принимает параметр GeoGebra, а не tparam¶
С числом t команда Point(путь, t) использует нормированный параметр пути
GeoGebra: t ограничивается отрезком [0, 1]; на окружности и эллипсе он
переходит в угол −π … π от первой оси, на отрезке — в долю пути от A к B, на
графике функции — в диапазон x вида, сохранённого в файле. Поэтому
Point(c, 0.25) на окружности с центром O — нижняя точка, а не точка под углом
0,25 рад. Значения update_tparam и tparam в ключевых кадрах используют
собственный параметр AnimaGeo (на окружности — угол в радианах). При импорте
Point(путь, t), чьё вычисленное положение расходится с сохранённым, остаётся
в сохранённом положении, и это отмечается как parametric_dependency_frozen.
loadCode может переопределить элементы GGB — граф нужно пересобрать¶
Скрипт может загрузить файл .ggb, а затем выполнить файл DSL, в котором имя
используется повторно:
Если в исходном .ggb имя E было точкой Intersect(β, k, 1), от которой
зависел m = Segment(B, E), то после переопределения имени зависимые команды
нужно пересчитать от нового E. Именно поэтому после loadCode() и
putCode() обязателен завершающий geo.rebuild(full=True) перед
updateAllGeometry(); иначе рендер может подхватить устаревшую геометрию
старого m.
Формулы¶
Эти правила относятся к формулам функций, коник и неявных кривых и к
уравнениям прямых — и из файла .ggb, и в DSL (Line("y = 2x + 1")).
x(A) — координата, x (A) — произведение¶
x( прямо перед скобкой всегда читается как координата: x(A) — абсцисса
точки A, 2x(A) — удвоенная абсцисса. Поэтому x(x + 1) — не x·(x + 1):
пишите x (x + 1) или x*(x + 1). С пробелом x (A) умножает x на точку
A, которая не является числом, и кривая не строится.
π, ℯ и типографские символы¶
π и pi — это π, а ℯ — число Эйлера в любой формуле, как бы в конструкции
ни назывались pi, e или E; 2π, πx, sin(πx), 2ℯ — произведения,
как 2x. e — число Эйлера только в функциях и неявных кривых: в уравнении
коники или прямой это обычное имя (число с именем e) — пишите там ℯ или
exp(1). В функциях и неявных кривых число с именем e использовать нельзя:
там e — всегда число Эйлера.
Знак минуса −, ·, ⋅ и × для умножения и ÷ для деления читаются как
-, * и /, поэтому формула, скопированная с экрана GeoGebra, разбирается
как есть.
Асимптоты периодических функций¶
График разбивается там, где формула не определена, чтобы линия не соединяла
две стороны асимптоты. У периодической функции — tan(x), cot(x),
sec(2x), csc(x − 1) — такие точки берутся в пределах 1000 единиц от
начала координат (не больше 1000 в каждом семействе периода); дальше, а также
там, где sympy не может их найти (tan(x^2)), график рисуется без разбиения.
Число, названное как математическая функция¶
k(x + 1) с числом k — произведение k·(x + 1), а числа с именами E, N,
S, O, I работают как обычные имена. Но имя математической функции перед
скобкой остаётся вызовом: при числе gamma запись gamma(x + 1) означает
Γ(x + 1). Пишите gamma*(x + 1).
Прочие вызываемые имена — объекты конструкции¶
Формула вызывает только математические функции (sin, sqrt, exp, log,
abs, floor, gamma, erf, …). Любое другое вызываемое имя — g(t),
foo(x) — объект конструкции, обычно другая функция. Если такого объекта в
конструкции нет, кривая не строится: импорт записывает
expression_parse_error, DSL пишет предупреждение в журнал.
Ограничения размера¶
Текст формулы ограничен 4000 символами. Формула, требующая огромного точного
вычисления, — 7^(9^9), (10^7)!, exp(10^9 log(7)), коника степени 10 000 —
отклоняется, как и цепочка функций, которая при встраивании превысила бы
2000 узлов выражения; при импорте оба случая отмечаются как
expression_parse_error. Формулы по-прежнему вычисляются sympy точно, поэтому
сервису, который разбирает загруженные файлы, стоит ограничивать время
загрузки.
Python¶
Циклический импорт между lib_elements и lib_vars¶
lib_elements.py импортирует из lib_vars.py (from .lib_vars import *), а
lib_vars.py импортирует из lib_elements.py
(from .lib_elements import Angle). Этот циклический импорт разрешается
корректно, только если lib_vars импортирован первым (как это делает
construction.py).
Если тесты или внешний код импортируют lib_elements первым, получится
ImportError: cannot import name 'Angle' from partially initialized module.
Правило: всегда импортируйте construction (или lib_vars) до
lib_elements:
from animageo.geo.construction import Construction # сначала
from animageo.geo.lib_elements import Point, Line # потом
ImportPolicy: строки DSL разбираются в __post_init__, а не в resolve()¶
Раньше parse_directive вызывался только внутри ImportPolicy.from_dict().
В итоге ImportPolicy(stroke_width_px='quantize:[1,3,6]') хранил саму строку,
и в resolve_overrides_only она попадала в elem.style['stroke_width_px'] как
есть — дальше ломалось преобразование толщины для manim (строка вместо числа →
линии не рисовались).
Решение: __post_init__ в ImportPolicy прогоняет каждое поле через
parse_directive, поэтому строки DSL работают одинаково и при загрузке из
JSON, и при передаче прямо в конструктор.
Следствие: если кому-то действительно нужно передать литеральную строку,
случайно совпадающую с префиксом DSL (маловероятно), её придётся завернуть в
вызываемый объект:
ImportPolicy(label_color=lambda *_: 'scale:1.5_as_literal').
У элементов, созданных через Python DSL, нет elem.ggb_raw¶
Поле ggb_raw заполняет только парсер .ggb. У элементов, добавленных через
loadCode и putCode, оно остаётся пустым словарём. Поэтому ImportPolicy —
это слой исключительно для GGB: правила, выведенные из сырых значений,
работают на импортированных элементах, а у элементов DSL исходного значения
GGB просто нет.
Решение: правила уровня типа и уровня имени переехали в StyleOverlay.
scene.style_config.overlay.apply(scene) вызывается автоматически в
addAllGeometry и работает одинаково для GGB и DSL через type(elem.data).
Для стилизации поверх импорта (per_type/per_name плюс autoPlaceLabels)
описывайте overlay в JSON. ImportPolicy остаётся для преобразований сырых
значений GGB (scale:, quantize:, remap:).
Историческая ошибка в единицах: размеры углов и точек из JSON множились на 0.02 и схлопывались¶
До перехода на канонические поля *_px некоторые размеры уровня сцены шли
через json_size_to_internal (× 0.02), а рендерер потом делил на ptUnit
ещё раз. На типичном холсте это превращало нормальный радиус дуги в
субпиксельный размер, и на сценах DSL дуга могла схлопнуться в невидимую
точку.
Сцены GGB это не задевало, потому что парсер писал arc_size_px прямо в
elem.style, минуя ang_rdefault уровня сцены. Поэтому баг проявлялся только
через Python DSL.
Решение: каноническая схема хранит размеры как семантические presets
(angle_radius.*, point_size.*, tick.*, arrow.*) и применяет их через
defaults по типам. Все значения *_px трактуются как пиксели и
преобразуются во время рендера в зависимости от целевого параметра manim:
координатные размеры делятся на ptUnit, а stroke_width и font_size идут
через масштаб manim * 100 / ptUnit. См.
tests/test_angle_units_regression.py.
tick_width_px — это толщина обводки, а не координатная длина¶
tick_length_px, tick_shift_px и tick_radius_px участвуют в геометрии
засечки, поэтому отрисовываются как координатные размеры: px / ptUnit.
tick_width_px используется иначе: засечки отрезков и векторов передают его в
Line(..., stroke_width=...) или VMobject.set_stroke(width=...) из manim.
Это та же система единиц, что и у stroke_width_px, поэтому правильное
преобразование — stroke_width_to_manim(tick_width_px, ptUnit), то есть
tick_width_px * 100 / ptUnit. Деление только на ptUnit делает обводку
засечки в 100 раз тоньше при рендере в SVG и экспорте. См.
tests/test_style_config_integration.py::TestPixelInvariantDecorations.
python -m animageo не должен импортировать Manim до проверки аргументов¶
Обычный import animageo по-прежнему экспортирует API, построенный на Manim.
Но путь CLI (python -m animageo file.ggb) остаётся лёгким до тех пор, пока
не проверены входные файлы, — чтобы об ошибках вроде отсутствующего файла
сообщать сразу, без инициализации тяжёлого рантайма.
Python DSL¶
putCode и loadCode — только движок exec (short_parser удалён)¶
Раньше DSL работал через обход AST (short_parser.py), который молча
игнорировал циклы, условия, def и именованные аргументы. После миграции
short_parser.py удалён — остался только движок exec (parsers/dsl/).
Именованный аргумент engine= у putCode и loadCode тоже убран.
Если внешний код передавал engine='legacy', теперь он получит
TypeError: got unexpected keyword argument 'engine'. Аргумент нужно убрать.
Упреждающие ссылки для имён со строчной буквы¶
Частый приём в скрипте сцены:
self.loadGGB(...) # заполняет A, B, R, Q …
self.loadCode('scene.py') # scene.py ссылается на x
self.addVar('x', 115) # x получает значение ПОСЛЕ loadCode
Движок exec поддерживает такую упреждающую ссылку для имён со строчной
буквы: FactoryDict.__missing__ автоматически создаёт заглушку Var с
data=None, Command запоминает имя, и оно разрешается во время rebuild. Для
имён с заглавной буквы — Rotat (опечатка в Rotate) падает во время
выполнения; так сделано намеренно, чтобы ловить опечатки.
Строки формул опережающих ссылок не допускают: имена внутри
Function("y = a*x^2") или f(x) = a x^2 должны существовать к моменту
выполнения этой строки. Иначе формула ни на что не ссылается, и элемент
остаётся неопределённым даже после того, как a будет добавлена.
Псевдонимы полей для обратной совместимости убраны¶
Короткие и абстрактные имена (.a, .c, .n, .r, .v, .M, .b, .x,
.dim, .angle, .original, .points, .end_points, .start_point)
полностью заменены описательными (.coords, .center/.offset, .normal,
.radius, .direction, .matrix, .value, .dimension, .tparam,
.size, .source, .vertices, .endpoints, .start). Псевдонимов больше
нет — старый код получит AttributeError.
Полный список — в docs/field_names.md. В формате JSON для
анимации по ключевым кадрам используются tparam_point и ключ tparam.