Architecture¶
Related documents:
docs/styles.md— the main style reference and layer-responsibility guide.docs/import_policies.md— details of GGB import/adaptation andImportPolicy.docs/field_names.md— table of GGB XML →ggb_raw→ggb_style/elem.style→ JSON/render.docs/guide/index.html— interactive HTML guide, in Russian (styling topics in §5–§7, reference in §11). It must be served from a local web server — it does not render on GitHub.
Processing pipeline¶
.ggb file (ZIP with XML) Python DSL code (string / .py)
| |
v v
┌─────────────┐ ┌────────────────┐
│ ggb_parser │────┐ │ parsers/dsl/ │
└─────────────┘ │ │ (exec engine) │
│ └────────┬───────┘
v v
┌──────────────────────────────────────────────────────┐
│ Construction │
│ elements[] (+ ggb_raw + ggb_style) │
│ vars[] │
│ commands[] │
│ state{} │
└──────┬───────────────────────────────────────────────┘
|
v
┌────────────────────────────────────────┐
│ applyStyle │
│ + GeoStyle (scene export ctx) │
│ + StyleConfig (builtin.json + │
│ user JSON, merged) │
│ + ImportPolicy (GGB raw → ggb_style) │
└──────┬─────────────────────────────────┘
|
v addAllGeometry:
┌──────────────┐
│ CreateMObject│◄─── reads via resolver.resolve:
│ ──> mobject│ │ elem.style → per_name → per_type →
└──────┬───────┘ │ ggb_style → defaults
| v
|
┌───────┴───────┐
v v
┌───────────┐ ┌───────────┐
│ svg_parser│ │ manim │
│ (Cairo) │ │ renderer │
└─────┬─────┘ └─────┬─────┘
v v
.svg .mp4
The parser records raw GGB values in elem.ggb_raw (point_size, point_style, line_thickness, line_type, line_opacity, arc_size, label_offset_px, obj_color, plus label/visibility flags such as label_mode, label_caption, show_object, show_label). obj_color stores the original r/g/b/alpha along with convenient hex / opacity aliases. Normalized visual keys go into elem.ggb_style; ImportPolicy decides how the raw values are further adapted — "as in GGB", a fixed value, a callable, quantization, or a remap. The resolver includes this layer only when import.enabled != false.
Style subsystem: three layers¶
Read priority (resolver.resolve(scene, elem, key)):
1. elem.style[key] ← explicit writes (DSL/API)
2. overlay.per_name[name][key] ← targeted override for one element
3. overlay.per_type[type][key] ← override for all elements of a type
4. elem.ggb_style[key] ← GGB import/adaptation, when enabled
5. defaults.by_type[type][key] ← per-type baseline (builtin.json + user JSON)
6. non-explicit elem.style[key] ← intrinsic fallback seeded by geometry classes
7. caller-supplied default= ← hard fallback
Each layer has a single responsibility:
DefaultsProfile— package-shippedbuiltin.json+ user JSON (deep merge). Per-type baseline in pixels (size_px,stroke_width_px,arc_size_px, …). Never writes intoelem.style.StyleOverlay—per_type/per_name+ automation (angle_radius,label_placement). Logically sits above GGB import and defaults; the resolver reads the overlay directly fromStyleConfig.GGBImportPolicy(i.e.ImportPolicy) — GGB transforms only:scale:/quantize:/remap:overelem.ggb_raw, with the result stored inelem.ggb_style. DSL elements are skipped (their raw layer is empty).
Unit contract: every *_px key in elem.style, elem.ggb_style, and defaults.<type> is in pixels. The renderer divides by ptUnit at draw time. The legacy GeoStyle.ang_rdefault / ang_rshift / ang_right / dot_size fields are pixel-valued as well.
Modules¶
| Module | Role |
|---|---|
animageo.py |
Scene, animations, CreateMObject (dispatcher + per-type _render_<type> renderers + _build_render_ctx + _renderer_for + _make_label), loadGGB, ImportPolicy wiring |
__main__.py |
CLI entry point (python -m animageo file.ggb -o out.svg): static vector track (svg/pdf/eps/tikz) and manim render track (png/gif/mp4/webm/mov, --keyframes for animation) |
ui.py |
TeX templates, create_label (9-point anchors), ValueLabel (DecimalNumber-backed fast value labels) + prewarm_decimal_glyphs, install_cyrillic_tex_template, NumberedFrame, CustomArrowTip |
labels.py |
Final label-text resolution: resolve_label_text, resolve_label_spec (structured LabelSpec for the fast value-label path), label modes (name/value/name+value) |
constants.py |
Z-index tiers (Z_FILL, Z_LINE, Z_POINT, Z_LABEL) and scaling coefficients |
keyframes.py |
Keyframe animation: JSON parsing, interpolators, easing, LabelOffsetInterpolator + attach_label_layouts |
label_placement.py |
Auto label placement: greedy solver + 8 candidates, compute_label_layout (pure), apply_label_layout (impure), compute_angle_label_center, compute_effective_arc_size_px (angle_radius), bbox LRU cache, EMA+hysteresis helpers |
export_layout.py |
Export canvas layout: ExportLayout, compute_export_layout, size/fit/anchor/padding normalization for export options |
render_config.py |
configure_render (output format / fps / transparency) + GIF palette fix |
logging_config.py |
configure_logging helper backing the CLI --verbose / --quiet / --log-level flags |
style/__init__.py |
GeoStyle (scene-level container: palette, export params, render flags), color utilities, re-export of constants/scaling |
style/config.py |
StyleConfig (top level), DefaultsProfile, StyleOverlay, deep_merge for multi-file JSON merging; resolve_style_input (maps a bare preset name to the packaged preset JSON path; paths/dicts pass through) + available_style_presets |
style/presets/*.json |
Packaged style presets (default, book_blue, book_green, book_purple, book_red), selectable by name via resolve_style_input |
style/resolver.py |
resolve(scene, elem, key) + resolved_style(scene, elem) + trace(…) for debugging the chain |
style/builtin.json |
Package-shipped per-type defaults in pixels; always loaded, user JSON merges on top |
style/schema.py |
Documentation and validation of JSON styles + import_policy sections (+ overlay in NEW_TOP_KEYS) |
style/scaling.py |
Named conversion functions: GGB px ↔ JSON style ↔ internal units |
style/import_policy.py |
ImportPolicy dataclass — GGB-only transforms (scale:, quantize:, remap:) |
style/dsl.py |
Mini-DSL for JSON values: const:, scale:, quantize:, remap: |
style/ggb_resolver.py |
resolve_ggb_style(ggb_raw) — reproduces the GGB import-style layer for faithful mode |
style/animatable.py |
Registry of animatable elem.style keys for keyframes v2 style tracks (interpolation kind per key; manim-free) |
style/colorspace.py |
sRGB ↔ Oklab conversion and color interpolation for keyframe style tracks (manim-free) |
style/enums.py |
Style vocabularies (Literal aliases + runtime tuples) and the GGB pointStyle → shape/fill/stroke decomposition table |
style/proxy.py + proxy.pyi |
StyleProxy — dict subclass with attribute-style access to elem.style and explicit-write tracking |
geo/construction.py |
Dependency graph, topological sort (Kahn), rebuild, apply, update_tparam, rename, add_and_build |
geo/lib_elements.py |
Point, Line, Segment, Ray, Angle, Polygon, Circle, Arc, CircleSector, Vector, LocusCurve, Text (+ re-export of Conic/Function/ImplicitCurve); human-readable fields (.coords, .center/.radius, .normal/.offset/.direction, .vertex/.size/.side1/.side2, .endpoints, .vertices, …); Element.__getattr__ forwards to .data |
geo/lib_conic.py |
Conic (3×3 matrix .matrix), invariant-based classification, as_ellipse/as_parabola/as_hyperbola/as_lines/as_point, Conic.from_string(equation) |
geo/lib_function.py |
Function — explicit y = f(x) (.expr, .var, .source), sympy parsing, If[...] → Piecewise, chained a ≤ x ≤ b, natural_singularities, lambdify |
geo/lib_implicit.py |
ImplicitCurve — arbitrary F(x, y) = 0 (.expr, .var_x/.var_y, .source), sympy parsing, two-argument lambdify |
geo/curve_sampling.py |
Viewport-aware adaptive sampler + Liang–Barsky clipping; analytic t-ranges for parabola/hyperbola; marching squares for implicit curves |
geo/lib_commands.py |
Geometric operations (400+ dispatchable entries in COMMAND_REGISTRY): intersections for all pairs (including numeric F/I), Conic commands (Center/Focus/Vertex/Axes/Directrix/Polar/Tangent), Ellipse/Hyperbola/Parabola constructors, function_T/conic_T/implicit_curve_T for the string DSL |
geo/lib_vars.py |
Measure(value, dimension), AngleSize, Boolean(value) |
geo/tparam.py |
Point ↔ curve-parameter (tparam) math for every path type; backend of Construction.tparam_from_coords and the keyframe system |
geo/utils.py |
is_number, is_angle_degrees, is_boolean |
parsers/ggb_parser.py |
XML extraction from .ggb, construction parsing, ggb_raw population; <expression type="conic/line/function/implicitpoly"> with an = sign; expressions are routed through dsl.run internally |
parsers/ggb_macro.py |
Custom-tool macro expansion: parses geogebra_macro.xml definitions and inlines macro calls into primitive commands before parsing (recursive expansion supported) |
parsers/ggb_generator.py |
Generates .ggb archives from a Construction (GeoGebra XML serialisation + ZIP packaging) |
parsers/dsl/ |
Python DSL (exec engine): transform.py (AST rewriter, loop scoping, name shaping, forbid list), registrar.py (__reg__/__reg_loop__/__reg_tuple__ + ContextVar), namespace.py (FactoryDict with __missing__ for 99 auto-discovered command factories backed by 433 dispatch signatures + math + style/hide/show helpers + forward refs for addVar variables), proxy.py (ElementProxy with __getattr__ into data plus +/-/*///abs arithmetic), sugar.py (f(x) = expr pre-pass), stub_gen.py (<scene>_stubs.pyi after loadGGB). Entry points: dsl.run(constr, code), with dsl.scope(c):, scene.putCode(code), scene.loadCode(path). Stubs: namespace.pyi, proxy.pyi. |
dsl.py + dsl.pyi |
Super-module — from animageo.dsl import * gives all factories and types in the IDE |
parsers/svg_parser.py |
Cairo rendering of manim objects to SVG |
exporters/tikz/ |
Semantic TikZ export (scene.exportTikZ): walks drawable elements in z-order and emits native TikZ through the same style resolver as the renderer |
exporters/jsxgraph/ |
Interactive JSXGraph export (scene.exportJSXGraph): transpiles the construction graph into live board.create calls; outputs html/js/spec/json/moodle |
exporters/construction_summary.py |
Compact JSON construction summary for AI style generation (construction_to_ai_summary, write_ai_summary) |
Dependency system¶
Every element of a construction has a level:
- Level 0 --- free points defined by coordinates
- Level N --- elements depending on elements of level N-1
Level 0: A(0,0) B(4,0) C(0,3)
Level 1: M = Midpoint(A, B) s = Segment(A, B)
Level 2: h = Segment(C, M)
When an element changes, only its dependents are rebuilt (lazy rebuild).
Label placement¶
label_placement.py operates in three modes:
1. Static one-shot (auto_place_labels(scene) / scene.autoPlaceLabels()):
- compute_label_layout (pure) collects LabelInfo for all visible labels, gathers obstacles (segments, circles, point cloud), picks a preferred direction for each label, and runs a priority-ordered greedy solver over 8 candidates.
- Returns dict[name, LabelPlacement] without mutations.
- apply_label_layout (impure) writes the result into elem.style['label_offset_px'] / elem.style['label_anchor'] and re-renders the scene.
- The Tex bbox is cached by (text, font_size) — keyframe snapshots and per-frame recomputation reuse the measurements.
2. play_keyframes + keyframe_snapshots=true:
- Pre-pass: save get_independents() + visibility, walk all keyframes (carry-forward values), call compute_label_layout at each one, collect snapshots list[dict[name, LabelPlacement]]. Restore the state, run rebuild(full=True).
- Before actual playback, _apply_keyframe_state(seq.keyframes[0], seq.element_info) is called: the first keyframe's values and show/hide are written into the construction, geo.rebuild() runs, then updateGeoElements() refreshes the affected mobjects. This makes the first rendered frame independent of the saved .ggb state.
- KeyframeSequence.attach_label_layouts(layouts) binds the snapshots to intervals: for kind='static' labels it creates a LabelOffsetInterpolator (linear or smooth interpolation of the ggb offset between snapshots); for kind='dynamic_angle' — AngleParams, so the bisector is recomputed per frame.
- In _play_keyframe_interval.on_frame the label pass runs AFTER geo.rebuild() (so ang.side1/.side2/.vertex are fresh) and BEFORE updateGeoElements() — labels render in the same mob.become(CreateMObject(elem)) as the geometry.
3. autoPlaceLabels(dynamic=True) under addUpdater:
- Installs a LabelTracker (a dict on the scene with prev_offset, prev_anchor, anchor_flip_counter, angle_params, cfg).
- updateVar after geo.rebuild() + updateGeoElements() calls _apply_dynamic_labels:
- for dynamic-angle labels — compute_angle_label_center without smoothing (the function is continuous by itself);
- for static labels — EMA (apply_ema_step) on the offset + Schmitt trigger on the anchor (anchor_hysteresis_step);
- throttling via solver_every_n_frames.
MC canonicalization (canonicalize_anchor=true) rewrites every label from (anchor=A, offset=O_A) to (anchor='MC', offset=O_A - edge_A·halfExtent·ptUnit_ggb). The visual center is preserved, but the anchor is always the same → interpolation produces no discrete jumps when the direction changes between keyframes. Off by default so existing snapshot tests keep passing.
Angle geometry taken into account by the layout:
- Multi-arc: angle labels move past the outermost arc when
elem.style['tick_count'] > 1— effective radius =arc_size_px + (lines − 1) · ang_rshift, the same formula used by both the renderer and the layout. - Two independent gaps:
angle_gap_arc_px(arc → label) andangle_gap_sides_px(angle sides → label bbox for narrow angles). The removed unifiedangle_gap_pxkey is no longer read. - Automatic arc-radius scaling (
overlay.angle_radius, default off):compute_effective_arc_size_pxis shared by the renderer (animageo.py:CreateMObject) and the layout (compute_label_layout+_collect_labels). Formulabase · (pivot_rad / angle) ** exp, clamped to[min_px, max_arm_fraction · min_arm_px]. The label automatically follows the scaled arc because the number comes from a single source. Per-element escape:elem.style['auto_radius'] = False. The renderer takesarc_size_pxthrough the resolver; DSL angles without an explicitelem.style['arc_size_px']use the pixel path via the builtin default (17 px).
Command dispatch¶
Functions in lib_commands.py are named by the pattern:
Type shortcuts:
| Letter | Type |
|---|---|
p |
Point |
l |
Line |
s |
Segment |
r |
Ray |
c |
Circle |
C |
Arc |
S |
CircleSector |
a |
Angle |
v |
Vector |
P |
Polygon |
i |
int / float |
m |
Measure |
A |
AngleSize |
b |
Boolean |
K |
Conic |
F |
Function |
I |
ImplicitCurve |
T |
str (DSL literal for string-arg constructors) |
Examples: midpoint_pp, intersect_lc, rotate_pAp, distance_pp,
intersect_KK (conic ∩ conic via the pencil method), intersect_Kl (conic ∩ line),
intersect_FK (function ∩ conic via substitution), intersect_II
(implicit ∩ implicit via marching squares + Newton), center_K,
focus_K, polar_pK, tangent_pK, ellipse_ppi, parabola_pl,
conic_ppppp (conic through 5 points), function_T (Function("y = x²")).
The dispatcher (Command.func()) looks the name up in COMMAND_REGISTRY, populated from the module's functions at import time.
Higher-order curves: Conic, Function, ImplicitCurve¶
Three classes covering everything that does not reduce to a line/circle/polygon:
┌───────────────────────────────────────────────────────────────────┐
│ Conic (lib_conic.py) │
│ matrix ∈ ℝ³ˣ³ symmetric — (x, y, 1)·matrix·(x, y, 1)ᵀ = 0 │
│ Lazy classification by the invariants det(matrix), │
│ det(matrix₃₃), rank → 9 subtypes (circle / ellipse / parabola / │
│ hyperbola / intersecting_lines / parallel_lines / double_line / │
│ point / empty). Canonical parametrization via │
│ eigendecomposition of matrix₃₃; degenerate cases decomposed │
│ through (±λ) eigenvectors. │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ Function (lib_function.py) │
│ sympy.Expr + free variable; lambdify('numpy') exactly once. │
│ GGB → sympy preprocessor: `^ → **`, `≤/≥ → <=/>=`, │
│ chains `a ≤ x ≤ b → (a ≤ x) & (x ≤ b)`, │
│ `If[cond, then [, else]] → Piecewise((then, cond), …)`. │
│ `natural_singularities` via `sympy.singularities`. │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ ImplicitCurve (lib_implicit.py) │
│ F(x, y) = 0 of arbitrary form (polynomial or not). │
│ `LHS = RHS` is normalized to `(LHS) − (RHS) = 0`. │
│ Two-argument lambdify; NaN-safe evaluation. │
└───────────────────────────────────────────────────────────────────┘
Curve rendering and viewport clipping¶
All three types render through one shared layer in curve_sampling.py:
sample_parametric(func, t_range, viewport, ...)— adaptive sampler. Starts from a uniform 32-point grid; where the step in scene MU exceedssegment_mu(default3 / ptUnit, i.e. 3 pixels), it inserts midpoints into long segments. A hard cap ofmax_samples=500per mobject guarantees no hangs on pathological expressions. Viewport clipping via Liang–Barsky, breaking the polyline when it leaves the frame.viewport_t_ranges_parabola(...)/viewport_t_range_hyperbola_branch(...)— analytically narrow the t-range to the viewport intersection in the conic's canonical frame. Parabola:|u| ≤ 2·√(p·v_max)(withv_min > 0— two ranges). Hyperbola (per branch):|t| ≤ min(acosh(u_max/a), asinh(v_max/b)).marching_squares(F, viewport, grid_n=128)— the classic 16-case algorithm forImplicitCurve. O(grid_n²) work, constant memory — no hangs. Linear interpolation of zeros on cell edges.
CreateMObject branches:
| Type | Strategy | Notes |
|---|---|---|
Conic (circle) |
manim.Circle with direct parameters |
closed contour, fill works |
Conic (ellipse) |
manim.Ellipse + .rotate().move_to(...) |
closed contour, fill works |
Conic (parabola) |
make_parabola_param + viewport_t_ranges_parabola + sample_parametric |
one or two polylines |
Conic (hyperbola) |
make_hyperbola_branch_param × 2 branches |
each branch is a polyline |
Conic (intersecting/parallel/double lines) |
as_lines() → Line.get_endpoints(corners) |
reuses the existing line renderer |
Conic (point) |
manim.Dot |
— |
Conic (empty) |
None |
nothing is drawn |
Function |
(t, f(t)) as parametric + splitting at natural_singularities + sample_parametric |
y = 1/x gives two polylines, tan(x) — several pieces between discontinuities |
ImplicitCurve |
marching_squares → list of segments, each a manim.Line |
lemniscates, "hearts", trigonometric patterns |
Intersections¶
All nontrivial pairs are implemented:
| Pair | Method | Function |
|---|---|---|
| Conic ∩ Line | Substitute the line parametrization into pᵀMp = 0 → quadratic |
intersect_Kl |
| Conic ∩ Conic | Pencil: det(λM₁ + M₂) = 0 → cubic → decompose λ·M₁+M₂ into a pair of lines → intersect_Kl |
intersect_KK |
| Conic ∩ Circle | Circle → Conic adapter + intersect_KK |
intersect_Kc |
| Conic ∩ Arc/Segment/Ray | intersect_Kl + membership filter |
intersect_KC/Ks/Kr |
| Function ∩ Line | Substitute y = f(x) into the line equation → sympy.solve first, then a sign-change scan with bisection fallback |
intersect_Fl |
| Function ∩ Conic | Substitution → 1D: pᵀ·M·p with p = (x, f(x), 1) |
intersect_FK |
| Function ∩ Circle | _circle_to_conic + FK |
intersect_Fc |
| Function ∩ Function | f₁(x) − f₂(x) = 0 |
intersect_FF |
| ImplicitCurve ∩ Line/Segment/Ray | Substitute the line parametrization into F → 1D | intersect_Il/Is/Ir |
| ImplicitCurve ∩ Conic/Circle/Function | Marching squares of F₁ → root-find F₂ along each segment → Newton refinement via scipy.fsolve |
intersect_IK/Ic/IF |
| ImplicitCurve ∩ ImplicitCurve | Same approach | intersect_II |
All functions have *i index variants for Intersect[..., n] and
reversed-argument variants (_Kl/_lK).