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

Python DSL в AnimaGeo

Обычное Python-окружение для создания геометрических конструкций. DSL-кодом может быть любой корректный код Python, а вызовы фабрик (Point, Midpoint, Intersect, …) одновременно выполняются и регистрируются в графе зависимостей Construction.

Статус: движок на основе exec — единственный движок DSL.

Точки входа:

  • scene.putCode(code) / scene.loadCode(filepath)
  • scene.loadGGB(filepath) — GGB → exec через ggb_parser
  • dsl.run(constr, code) — прямой вызов
  • with dsl.scope(constr): ... — привязка ContextVar для файлового режима

Содержание

Быстрый старт

С отдельным объектом Construction:

from animageo.geo.construction import Construction
from animageo.parsers import dsl

c = Construction()
dsl.run(c, """
    A = Point(0, 0)
    B = Point(4, 0)
    C = Point(0, 3)
    p, s1, s2, s3 = Polygon(A, B, C)
    M = Midpoint(A, B)
    style(M, stroke='#ff0000', size=10)
""")

Со сценой Manim (AnimaGeoScene):

class MyScene(AnimaGeoScene):
    def construct(self):
        self.putCode("""
            A = Point(0, 0)
            B = Point(3, 4)
            M = Midpoint(A, B)
        """)
        self.addAllGeometry(show=True)

Фабрики также можно импортировать напрямую в файле .py, чтобы получать подсказки IDE:

from animageo.parsers.dsl.namespace import Point, Midpoint, style
# Для прямых вызовов нужен активный Construction в ContextVar —
# см. dsl.registrar.set_current_construction либо используйте
# scene.putCode(code).

Синтаксис

Доступны все конструкции Python:

# циклы
for i in range(5):
    p = Point(i, 0)            # создаёт p, p_2, p_3, p_4, p_5

# условия
for i in range(10):
    if i % 2 == 0:
        p = Point(i, 0)

# функции
def triangle(prefix, side):
    A = Point(0, 0, name=f"{prefix}_A")
    B = Point(side, 0, name=f"{prefix}_B")
    C = Point(side/2, side*0.866, name=f"{prefix}_C")
    return A, B, C

triangle("t1", 3)
triangle("t2", 5)

# списковые включения тоже работают
radii = [r for r in range(1, 6)]
circles = [Circle(Point(0, 0, name=f"O_{i}"), r) for i, r in enumerate(radii)]

# математика — import не нужен: функции и константы math
# (pi, sqrt, …) доступны глобально в пространстве имён DSL
A = Point(sqrt(2), pi/2)

Фабрики

Любое имя в CamelCase, которому соответствует диспетчеризуемая функция в animageo/geo/lib_commands.py, автоматически становится фабрикой. Ручная регистрация не требуется.

Основные фабрики:

Конструкторы Команды
Point, Line, Segment, Ray, Circle, Arc, CircleSector, Angle, Polygon, Vector, Conic, Function, ImplicitCurve Midpoint, Distance, Length, Radius, Center, Vertex, Focus, Intersect, AreCollinear, PerpendicularLine, Tangent, Polar, Rotate, Dilate, … (всего 100 фабрик команд, которые диспетчеризуются в 477 сигнатур для конкретных сочетаний типов аргументов в COMMAND_REGISTRY)

Неизвестное имя вызывает NameError: ошибка всегда явная и никогда не превращается в незаметную пустую операцию.

Формулы

Function, Conic, ImplicitCurve и Line принимают уравнение строкой. Строка вида name(var) = expr — сокращённая запись Function:

f = Function("y = x^2 + 1")
c = Conic("x^2 + y^2 = 4")
h = ImplicitCurve("x^3 + y^3 = 3x y")
l = Line("y = 2x + 1")
f(x) = x^2 + 1         # f = Function("y = x^2 + 1")
g(t) = 2t + 1          # функция от t

Line читает строковый литерал как уравнение; Line(A, B), Line(s) и другие формы строят прямую, как и раньше. Уравнение, которое не задаёт прямую (Line("y = x^2")), оставляет элемент неопределённым.

Формула может ссылаться на объекты конструкции и тогда следует за ними: числа и ползунки, логические значения, измерения, углы, координаты точек и векторов (x(A), y(A)), другие функции (g(x)). Логическое значение — условие в If(b, x, -x), а в остальных местах считается как 0 или 1 (y = x + b):

a = 2
A = Point(1, 3)
p(x) = a x^2                               # следует за a
q = Function("y = x(A) + x")               # следует за A
g(x) = x^2
r(x) = g(x - 2) + 1                        # следует за g
k = Conic("(x - x(A))^2 + (y - y(A))^2 = 4")
m = Line("y = a x + 1")                    # следует за a
b = AreCollinear(A, Point(0, 0), Point(2, 6))
s = Function("y = If(b, x, -x)")           # следует за b

Такая формула хранится как команда, к которой дописаны имена, на которые она ссылается, — Function("y = a x^2", a), — и перечитывается с их текущими значениями при каждой пересборке. Объекты должны существовать до того, как записана формула; имя, определённое через само себя (f = Function("y = f(x) + 1") при уже существующей f, A = Midpoint(A, B)), вызывает ValueError о цикле зависимостей.

Синтаксис формул:

  • ^ (или **) — степень. Знак * можно опускать: 2x, k x, 2(x + 1), (x + 1)(x - 1), x (x + 1).
  • x(A) без пробела перед скобкой — абсцисса точки A, поэтому x(x + 1) — не произведение: пишите x (x + 1) или x*(x + 1). 2x(A) — удвоенная абсцисса A.
  • Число перед скобкой — произведение: k(x + 1) означает k*(x + 1), если только число не названо как математическая функция: gamma(x + 1) — это Γ(x + 1).
  • Формула вызывает только математические функции: sin, cos, tan, cot, sec, csc, asin, acos, atan, sinh, cosh, tanh, exp, log, ln, sqrt, cbrt, root, abs, sign, floor, ceiling, round, frac, min, max, gamma, erf, … — функции и неявные кривые понимают и написания GeoGebra (arcsin, sgn, ceil, lg, ld) и If(cond, a, b). Любое другое имя, которое формула вызывает, — объект конструкции. round(x) округляет половину от нуля: round(2.5) — 3, round(-2.5) — −3.
  • pi и π — это π, ℯ — число Эйлера. e — тоже число Эйлера в функциях и неявных кривых; в уравнении коники или прямой это обычное имя — пишите там ℯ или exp(1). 2π, πx, 2ℯ — произведения, как 2x.
  • Типографские символы тоже читаются: знак минуса −, ·, ⋅ и × для умножения, ÷ для деления.
  • Функция, которая вызывает другую функцию с ограниченной областью определения, не определена там же, где и та.
  • Уравнение коники или прямой должно быть многочленом в том виде, как записано: (x^2 + x)/x = y — не коника.
  • Текст формулы ограничен 4000 символами, а формула, требующая огромного точного вычисления (7^(9^9), (10^7)!), отклоняется.

Формула, которую не удалось прочитать или которая называет то, чего нет в конструкции, оставляет свой элемент неопределённым, а в журнал пишется предупреждение.

Имена элементов

Из переменной слева

A = Point(0, 0)        # элемент регистрируется как "A"

В цикле — автоматическая уникализация

for i in range(3):
    p = Point(i, 0)    # элементы: p, p_2, p_3

В функции — также с уникализацией

Тело def считается областью цикла:

def make():
    A = Point(0, 0)
    return A

make()    # элемент: A
make()    # элемент: A_2

Явное имя через name=

def triangle(prefix):
    A = Point(0, 0, name=f"{prefix}_A")   # элемент: <prefix>_A
    B = Point(1, 0, name=f"{prefix}_B")
    return A, B

Если передан name=, а имя уже занято, возникает ValueError: тихого столкновения имён не будет. Python-переменная A всё равно привязывается к proxy-объекту.

Распаковка кортежа для команд с несколькими результатами

a, b = Intersect(c, L)                    # две точки пересечения
p, s1, s2, s3 = Polygon(A, B, C)          # многоугольник и три стороны

Трансформер добавляет в вызов _outputs=N, чтобы фабрика выделила нужное количество имён.

Если нужна одна конкретная точка из нескольких пересечений, используйте индекс с единицы: p = Intersect(c, L, index=1) или позиционную форму p = Intersect(c, L, 1).

Порядок пересечений является частью публичного контракта. Первый элемент распакованного кортежа соответствует Intersect(..., index=1), второй — Intersect(..., index=2) и так далее. Для окружностей применяется эвристика, похожая на GeoGebra: если окружность построена через уже известные точки, то совпавшие с ними точки пересечения идут первыми в порядке входов окружности; затем учитываются точки второго входного объекта, а оставшиеся пересечения сохраняют внутренний детерминированный порядок. Он должен совпадать при импорте .ggb, в Python DSL и в путях экспорта/JSXGraph.

Доступ к полям

Через proxy-объект, который возвращает фабрика:

A = Point(3, 4)
print(A.x, A.y)           # 3.0, 4.0
print(A.coords)           # [3., 4.]

circ = Circle(Point(0, 0), 5)
print(circ.center)        # [0., 0.]
print(circ.radius)        # 5

s = Segment(Point(0, 0), Point(3, 4))
print(s.start, s.end, s.length)

Полный список приведён в разделе Поля геометрических объектов.

Чтение всегда отражает актуальное состояние: после любого dsl.run поля содержат текущие значения.

Стили

elem.style — это StyleProxy, унаследованный от dict. Одновременно работают оба варианта API.

Через атрибуты

A = Point(3, 4)
A.style.stroke = "#ff0000"
A.style.size_px = 12
A.style.label_visible = True

Как словарь — прежний API сохранён

A.style["stroke"] = "#ff0000"
A.style.get("stroke", "black")
"stroke" in A.style
for k, v in A.style.items(): ...
json.dumps(A.style)           # продолжает работать

Групповые функции

style(A, B, C, stroke="#f00", size=10)
hide(A, B)
show(C)

Отсутствующий атрибут возвращает None, а не AttributeError. Это повторяет семантику CSS: if A.style.stroke: означает «задан ли этот стиль?».

Что запрещено

На этапе преобразования возникает DSLSyntaxError с номером строки и столбца:

  • global x / nonlocal x;
  • A += expr — составное присваивание;
  • (A := expr) — walrus-оператор;
  • A: int = 5 — аннотированное присваивание со значением;
  • A = B = expr — цепочка присваиваний;
  • имя слева, начинающееся с подчёркивания: такие имена зарезервированы для фантомных объектов.

На этапе преобразования молча удаляются:

  • import module / from module import name — они могут оставаться в исходнике для статического анализа IDE, но имя не привязывается во время выполнения.

Во время выполнения вызывают NameError:

  • open, eval, exec, __import__, compile — этих функций нет в sandbox.

Что поддерживает exec-движок

  • for, if, while, def, включения и lambda — как в Python;
  • **kwargs в фабриках: Point(3, 4, name='A');
  • неизвестная команда в CamelCase → NameError с понятным сообщением;
  • повторное присваивание в той же области обновляет элемент, а в цикле или def автоматически создаёт name_2, name_3, …;
  • доступ к полям через proxy: A.x, A.y, circ.center, seg.length;
  • стили как атрибуты: A.style.stroke = '#f00';
  • f-строки, распаковка кортежей (a, b = Intersect(c, l)) и арифметика над proxy (A - B создаёт команду Sub);
  • запись функций f(x) = x^2 + 1, g(t) = 2t + 1 (см. Формулы);
  • опережающие ссылки для имён со строчной буквы: B = Rotate(R, x*deg, Q) работает, даже если x ещё не определена — значение можно позже передать через scene.addVar('x', 115). Исключение — строки формул: имена в них должны существовать к моменту, когда записана формула;
  • имя, определённое через само себя (A = Midpoint(A, B)), вызывает ValueError о цикле зависимостей, а не зависание.

Текущие ограничения

  • Стабы .pyi объявляют 86 сигнатур фабрик: 51 подробную типизированную и 35 общих *args: Any. Конструкторы элементов (Point, Line, …) отдельно типизированы в proxy.pyi. Для редко используемых команд IDE может показать Any вместо точного типа. Расширять список нужно в namespace.pyi; проверка запускается командой python3 -m animageo.parsers.dsl._regen_stubs.
  • Sandbox блокирует open, eval, exec, __import__, compile: во время выполнения возникает NameError. Верхнеуровневый import молча удаляется, чтобы оставить возможность использовать IDE-стабы, но имя во время выполнения не связывается.