Перейти к содержанию

Особенности и типичные ошибки

Здесь собрано неочевидное поведение 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).

Правила:

  1. Никогда не вызывайте set_default в коде, который выполняется на каждом рендере. Из setStyle он убран; цвет подписи передаётся явно (col_label в _build_render_ctx → create_label).
  2. Если глобальное значение по умолчанию действительно нужно (скрипты, примеры) — задайте его один раз на процесс или сделайте вызов идемпотентным: сначала cls.set_default() (сброс к _original__init__), затем cls.set_default(color=...).
  3. set_default() без аргументов полностью восстанавливает исходный __init__ — на этом держатся тестовые фикстуры (tests/test_mobject_default_leak.py).
  4. 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 → пропадали и маркер, и подпись).

Правила:

  1. tex_template — только аргумент конструктора.
  2. Шаблон с поддержкой кириллицы устанавливается глобальным значением по умолчанию один раз при инициализации сцены: ui.install_cyrillic_tex_template() (вызывается из AnimaGeoScene.__init__). Это НЕ Mobject.set_default — это обычное идемпотентное присваивание в config.tex_template, поэтому цепочка partialmethod не накапливается. Шаблон, установленный вызывающей стороной (не штатный), не трогается.
  3. Неудачная компиляция подписи не должна ронять элемент: 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):

/opt/homebrew/opt/python@3.13/bin/python3.13 -m pytest ...

Updater на анимируемом объекте никогда не видит промежуточных значений

Если add_updater(func) навешан на тот же ValueTracker, который анимируется через .animate.set_value(), то внутри func вызов mob.get_value() (где mob — параметр, переданный manim) вернёт только начальное и конечное значения, но никогда промежуточные. Причина: во время анимации manim создаёт копии начального и конечного состояний объекта и вызывает updater на этих копиях, а не на интерполируемом оригинале.

Рабочие подходы:

  1. Объект-часовой (используется в play_keyframes): updater живёт на отдельном невидимом Mobject, а ValueTracker читается через замыкание:

    progress = ValueTracker(0)
    sentinel = Mobject()
    def on_frame(mob):
        t = progress.get_value()  # замыкание — читает оригинал
        ...
    sentinel.add_updater(on_frame)
    self.add(progress, sentinel)
    self.play(progress.animate(...).set_value(1), ...)
    

  2. Замыкание вместо параметра (используется в addUpdater): updater стоит на трекере, но читает захваченную переменную, а не параметр mob:

    tracker.add_updater(lambda v, self=self: self.updateVar(tracker))
    #                                                       ^^^^^^^ замыкание, не v
    

Не работает:

tracker.add_updater(lambda v: do_something(v.get_value()))
#                              ^^^^^^^^^^^ v — копия, а не оригинал

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 на каждом кадре. Поддерживаются два способа это включить:

  1. play_keyframes при keyframe_snapshots=true — on_frame запускает пересчёт автоматически.
  2. 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, в котором имя используется повторно:

E = Rotate(D + Vector(r2, 0), ang2 * deg, D)

Если в исходном .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.