Imported from frederico-kluser/manim-agent-skill (
.claude/skills/manim-grafos-redes/SKILL.md). Install upstream withnpx skills add frederico-kluser/manim-agent-skill --skill manim-grafos-redes. Copyright stays with the author.
Grafos e redes — Graph, DiGraph
"Graph" no Manim é ambíguo e a ambiguidade custa tempo. manim.mobject.graph
é teoria dos grafos: vértices e arestas. manim.mobject.graphing é plotagem:
eixos e curvas. A primeira linha do módulo diz isso literalmente
(mobject/graph.py:1): "Mobjects used to represent mathematical graphs (think
graph theory, not plotting)". Se o pedido é "plota o seno", você está na skill
errada — vá para manim-graphs-plots.
Procedência de cada afirmação
Quatro marcadores, válidos para o arquivo inteiro:
- [FONTE] — lido no ManimCE 0.21.0 instalado em
.venv/lib/python3.12/site-packages/manim/, ou no networkx 3.6.1 do mesmo venv, com arquivo e linha. Afirmação forte. - [ÍNDICE] — assinatura copiada de
api/manim-ce-index.tsv/api/manim-ce-methods.tsv. - [HOJE] — contado nesta sessão, 2026-08-19, com
grep/awk/sed. Nenhum render, nenhum ffmpeg, nenhuma GPU. - [NÃO VERIFICADO] — dedução de leitura do código que ninguém executou. Está marcado onde aparece, e §17 lista tudo junto.
Nada neste arquivo foi renderizado. Os exemplos são referência de API, não
receitas testadas. Antes de commitar qualquer cena que saia daqui, rode o ciclo
de manim-verificacao-visual: renderizar rápido → OLHAR o PNG → corrigir.
Cartão de referência — o sintoma manda na seção
| O que você quer / o que aconteceu | Onde ler |
|---|---|
| "isso é grafo mesmo, ou eu quero 5 caixas e setas?" | §1 — leia antes de tudo |
| a assinatura, o que existe na categoria | §2 |
g[…], g.vertices, g.edges, o que o objeto guarda |
§3 |
| vértice: tamanho, cor, rótulo, mobject próprio | §4 |
| aresta: cor, espessura, curva, ponta de seta | §5 |
| escolher layout / posicionar à mão / layout próprio | §6 |
| o grafo saiu diferente a cada render | §7 — e é fatal em cena em partes |
troquei o layout e o grafo voltou para o centro / perdeu o scale |
§8 |
| inserir/remover vértice ou aresta com animação | §9 |
| destacar caminho, piscar aresta, correr um ponto pela rede | §10 |
| o grafo se desmontou e as arestas pararam de seguir | §10.4 |
| o render ficou lento depois que entrou o grafo | §11 |
from_networkx, caminho mínimo, o resto do networkx |
§12 |
| o grafo sumiu no fundo branco / os rótulos se sobrepõem | §4.5 e §13 |
| uma cena de referência inteira, comentada | §14 |
| conferir sem renderizar | §15 |
ValueError: Could not find … in vertices or edges |
§3.2 |
ValueError: The layout '…' is neither a recognized layout… |
§6.6 |
nx.NetworkXException: G is not planar |
§6.2 |
ValueError: The tree layout requires the root_vertex parameter |
§6.4 |
1. A pergunta que vem antes de qualquer código
Um diagrama de arquitetura com 5 caixas rotuladas não é um Graph. Esta é
a decisão que mais custa retrabalho nesta área, e o argumento tem número.
[HOJE] No deck consumidor ~/Projects/aulas — 11 arquivos de cena, ~77
classes de parte em produção — a contagem de ocorrências de Graph(, DiGraph
ou networkx é zero. Todos os diagramas (worktrees, orquestrador, MCP,
skills, harness) são VGroup + Rectangle + Arrow/Line posicionados à mão:
33 Line( e 9 Arrow( espalhados nos 11 arquivos. Não é ignorância da
API: é que layout automático briga com legibilidade.
Por que Graph perde num diagrama de explicação
O que Graph te dá |
Por que atrapalha num diagrama de 5 caixas |
|---|---|
| posição calculada por algoritmo | você já sabe onde cada caixa deve ficar; o algoritmo não sabe |
| aresta que segue o vértice | as caixas não se mexem |
vértice = Dot/LabeledDot circular |
um serviço, um repositório e um processo querem formas diferentes |
aresta = Line/Arrow reta |
você quer cotovelo, curva, tracejado, rótulo no meio |
spring/random mudam a cada render (§7) |
um diagrama que muda de lugar entre a parte 3 e a parte 4 do vídeo é defeito puro |
A regra prática
| Situação | Use |
|---|---|
| ≤ ~8 nós, você sabe onde cada um vai, cada um tem forma/rótulo próprio | VGroup + Rectangle/RoundedRectangle + Arrow, à mão — manim-mobjects e manim-layout-posicionamento |
| a topologia é o assunto (grau, caminho, ciclo, árvore, bipartido, corte) | Graph/DiGraph — esta skill |
≥ ~12 nós, ou os dados vêm de fora (CSV, API, networkx) |
Graph/DiGraph, com layout determinístico (§7) |
| o grafo muda durante a cena (nó entra, aresta some, árvore expande) | Graph — §9 é toda sobre isso |
você quer o algoritmo de layout mas não quer os Dot |
Graph só para calcular, e leia g._layout — ou use nx.spring_layout direto (§12.3) |
Escolher errado tem conserto barato num sentido só: sair do Graph para o
desenho à mão é reescrever; começar à mão e depois precisar de topologia é
raro. Na dúvida em aula, comece à mão.
2. O inventário completo da categoria
[HOJE] awk -F'\t' '$3=="mobject/graph"' api/manim-ce-index.tsv devolve
5 linhas: 4 classes/protocolos e 2 constantes re-exportadas (BLACK,
TYPE_CHECKING). É a menor categoria acionável do índice — e a mais densa em
parâmetros por classe.
| Símbolo | O que é | No from manim import *? |
|---|---|---|
Graph |
grafo não dirigido | sim [FONTE] __all__ do módulo, e api/manim-ce-toplevel.md:103 |
DiGraph |
grafo dirigido (arestas com ponta) | sim — manim-ce-toplevel.md:82 |
GenericGraph |
base abstrata dos dois; é dela que vêm todos os métodos públicos | não — from manim.mobject.graph import GenericGraph |
LayoutFunction |
Protocol para escrever um layout próprio |
não — mesmo import |
[ÍNDICE] Herança (api/manim-ce-inheritance.txt:153-155):
VMobject
GenericGraph
DiGraph
Graph
Ou seja: um grafo é um VMobject. Ele aceita scale, shift, move_to,
set_stroke, copy, become, save_state — todo o vocabulário de
manim-mobjects. [HOJE] awk -F'\t' '$1=="Graph"' api/manim-ce-methods.tsv
dá 250 métodos, dos quais 8 são próprios do assunto grafo
(add_vertices, remove_vertices, add_edges, remove_edges,
change_layout, from_networkx, update_edges, __getitem__) — os outros 242
são de Mobject/VMobject e pertencem às skills irmãs.
A assinatura, idêntica nas três classes
[ÍNDICE] Graph, DiGraph e GenericGraph têm exatamente a mesma:
Graph(vertices: Sequence[Hashable],
edges: Sequence[tuple[Hashable, Hashable]],
labels: bool | dict = False,
label_fill_color: str = BLACK,
layout: LayoutName | dict[Hashable, Point3DLike] | LayoutFunction = "spring",
layout_scale: float | tuple[float, float, float] = 2,
layout_config: dict | None = None,
vertex_type: type[Mobject] = Dot,
vertex_config: dict | None = None,
vertex_mobjects: dict | None = None,
edge_type: type[Mobject] = Line,
partitions: Sequence[Sequence[Hashable]] | None = None,
root_vertex: Hashable | None = None,
edge_config: dict | None = None) -> None
Repare: não existe **kwargs. Graph(..., color=BLUE) levanta
TypeError. Cor de vértice vai em vertex_config, cor de aresta em
edge_config (§4, §5) — ou depois, com g.set_stroke(...).
Os 8 métodos próprios
[ÍNDICE] api/manim-ce-methods.tsv, todos definidos em GenericGraph
exceto update_edges:
GenericGraph.add_vertices(self, *vertices: Hashable, positions: dict | None = None,
labels: bool = False, label_fill_color: str = BLACK,
vertex_type: type[Mobject] = Dot, vertex_config: dict | None = None,
vertex_mobjects: dict | None = None)
GenericGraph.remove_vertices(self, *vertices)
GenericGraph.add_edges(self, *edges: tuple[Hashable, Hashable],
edge_type: type[Mobject] = Line, edge_config: dict | None = None, **kwargs)
GenericGraph.remove_edges(self, *edges: tuple[Hashable]) -> VGroup
GenericGraph.change_layout(self, layout=..., layout_scale=2, layout_config=None,
partitions=None, root_vertex=None) -> Graph
GenericGraph.from_networkx(nxgraph, **kwargs) # classmethod
Graph.update_edges(self, graph) -> Self # e DiGraph.update_edges
GenericGraph.__getitem__(self, k) -> Mobject
Os privados _add_vertex, _add_edge, _remove_vertex, _remove_edge,
_create_vertex(es), _add_created_vertex, _populate_edge_dict,
_empty_networkx_graph existem e são estáveis, mas o público faz o mesmo com
plural e com .animate (§9). Não os chame.
3. O objeto por dentro
3.1 Os atributos que você pode ler
[FONTE] mobject/graph.py:563-659 (o __init__):
| Atributo | O que guarda |
|---|---|
g.vertices |
dict[Hashable, Mobject] — vértice → o Dot/LabeledDot |
g.edges |
dict[tuple, Mobject] — a tupla exatamente como você escreveu → a Line |
g._graph |
o nx.Graph/nx.DiGraph de verdade. É por aqui que se chama nx.shortest_path (§12.2) |
g._layout |
dict[vértice, Point3D] — as posições que o layout calculou. Não acompanha shift/scale (§8) |
g._labels |
dict[vértice, Mobject] dos rótulos |
g._vertex_config, g._edge_config, g._tip_config |
os kwargs por vértice / por aresta / da ponta de seta |
g.default_vertex_config, g.default_edge_config |
os kwargs que valem para todo mundo |
Só vertices e edges são API documentada; os _ são internos e o código
deste módulo os usa o tempo todo. Ler é seguro; escrever direto é como o
próprio Manim se enrola (§5.2).
3.2 g[…] — vértice ou aresta, e o erro que mente
[FONTE] graph.py:672-696:
g[1] # o Dot do vértice 1
g[(1, 2)] # a Line da aresta (1, 2)
A docstring do método promete KeyError; o corpo levanta ValueError:
raise ValueError(f"Could not find {k} in vertices or edges") # graph.py:695
Consequência prática: um try: ... except KeyError: em volta de g[e]
não pega nada e a cena morre. Se você precisa de acesso tolerante, use
g.edges.get(e) sobre o dicionário, que é um dict de verdade.
E a causa nº 1 desse ValueError numa aresta é a §5.3: você escreveu (2, 1)
e a aresta está registrada como (1, 2).
3.3 A ordem dos submobjects e o z_index=-1
[FONTE] graph.py:656-659:
self.add(*self.vertices.values())
self.add(*self.edges.values())
self.add_updater(self.update_edges)
Vértices primeiro, arestas depois — mas as arestas nascem com z_index=-1
(graph.py:1573 no Graph, 1781 no DiGraph, 1057 no _add_edge), então desenham atrás
dos vértices. É isso que faz a linha sumir sob o Dot em vez de cruzá-lo.
A armadilha: Mobject.set_z_index(valor, family=True) — e family=True é
o default [ÍNDICE]. Um g.set_z_index(2) para pôr o grafo na frente de um
retângulo de fundo achata o -1 das arestas e elas passam a cruzar os
vértices. Use g.set_z_index(2, family=False).
Consequência secundária de "vértices primeiro": Create(g) desenha
todos os pontos e só depois as linhas — Create tem lag_ratio=1.0 por
default [ÍNDICE], então é sequencial. Para desenhar "nó, aresta, nó, aresta",
não use Create(g): componha a mão com LaggedStart sobre g.vertices e
g.edges na ordem que você quer (manim-composicao-ritmo).
3.4 O updater: o que ele garante
O grafo carrega um updater permanente, registrado no __init__. Ele é o
motivo de a aresta seguir o vértice quando você anima g[1].animate.move_to(…).
[FONTE] Graph.update_edges (graph.py:1579-1588):
def update_edges(self, graph) -> Self:
for (u, v), edge in graph.edges.items():
edge.set_points_by_ends(
graph[u].get_center(),
graph[v].get_center(),
buff=self._edge_config.get("buff", 0),
path_arc=self._edge_config.get("path_arc", 0),
)
return self
DiGraph.update_edges (graph.py:1790-1806) é igual, com três diferenças:
passa os Mobjects (graph[u], não .get_center()), e faz
tip = edge.pop_tips()[0] antes / edge.add_tip(tip) depois — porque uma
ponta de seta deforma se você reescrever os pontos da linha por baixo dela.
Três consequências que valem dinheiro:
Graphliga CENTRO a CENTRO;DiGraphliga BORDA a BORDA. [FONTE]Line._set_start_and_end_attrs(line.py:163-172) chama_pointify(start, vect), que para um Mobject devolveget_boundary_point(direction). Passando um ponto, devolve o ponto. Por isso a seta doDiGraphpara na circunferência do vértice, e a linha doGraphentra por baixo dele.- O updater só roda enquanto o grafo estiver na lista de mobjects da cena. Se ele sair de lá, as arestas congelam (§10.4).
- O updater derruba o cache de frame estático — e não só o do grafo (§11).
3.5 O bug do buff e do path_arc no updater
Olhe de novo: self._edge_config.get("buff", 0). Mas self._edge_config é
indexado por tupla de aresta, não por nome de opção (§3.1). A chave
"buff" nunca existe ali.
[FONTE] Resultado, lendo os dois caminhos:
- na construção (
graph.py:1569-1577) a aresta nasce comLine(start, end, z_index=-1, **self._edge_config[(u,v)])— ou seja, o seubuff/path_arcé aplicado; - no primeiro frame em que o updater roda,
set_points_by_endsé chamado combuff=0, path_arc=0e desfaz os dois.
Ou seja: edge_config={"buff": 0.2} funciona no frame estático e some assim que
a cena anda. Idem path_arc — arestas curvas endireitam.
[NÃO VERIFICADO] — o mecanismo está lido linha a linha, o efeito visual não foi renderizado.
As saídas, em ordem de preferência:
# 1. Não precise de buff no Graph: use DiGraph com ponta de tamanho zero,
# que já para na borda do vértice (é o idioma da própria doc, graph.py:1733-1762)
g = DiGraph(V, E, labels=True,
edge_config={"stroke_width": 2, "tip_config": {"tip_length": 0, "tip_width": 0}})
# 2. Se você precisa MESMO de arestas curvas e o grafo NÃO se move:
g.clear_updaters() # depois de posicionar tudo — ver §11
Não tente g._edge_config["buff"] = 0.2: isso injetaria uma chave que o
_populate_edge_dict e o _remove_edge tratam como se fosse uma aresta.
4. Vértices
4.1 Os quatro parâmetros, e qual vence
[FONTE] graph.py:589-617, na ordem em que o código resolve:
| Parâmetro | Escopo | Vence quem |
|---|---|---|
vertex_type |
a classe de todo vértice | default Dot |
vertex_config |
kwargs do construtor | ver 4.2 |
labels |
rótulo | promove Dot → LabeledDot (4.3) |
vertex_mobjects |
um mobject pronto por vértice | sobrepõe tudo — self.vertices.update(vertex_mobjects) (graph.py:617) |
vertex_mobjects={"api": Rectangle(...)} é a válvula de escape quando um nó
precisa de outra forma. Mas repare: se você chegou nesse ponto para 3 nós de 5,
§1 já respondeu — desenhe à mão.
4.2 vertex_config é um dicionário de duas caras
[FONTE] graph.py:604-612:
default_vertex_config = {k: v for k, v in vertex_config.items() if k not in vertices}
self._vertex_config = {v: vertex_config.get(v, copy(default_vertex_config)) for v in vertices}
A regra: chave que é um vértice → configuração daquele vértice; chave que não é → default para todos.
Graph(V, E, vertex_config={"radius": 0.18, "color": BLUE, # vale para todos
7: {"fill_color": RED}}) # só para o 7
Duas armadilhas nascem daí:
- Config por vértice SUBSTITUI, não mescla.
7: {"fill_color": RED}é o dicionário inteiro do vértice 7 — ele não herdaradius: 0.18. Repita o que precisar:7: {"radius": 0.18, "fill_color": RED}. (Noadd_verticesé diferente:_create_vertexfazbase_vertex_config.update(vertex_config)—graph.py:726-729— e ali mescla. A assimetria é real.) - Vértice com nome de kwarg quebra em silêncio. Se um vértice se chama
"color"e você passavertex_config={"color": BLUE},"color" in verticesé verdadeiro →BLUEvira "a config do vértice color" →Dot(**BLUE)→TypeError. Vértices com nomes de string em português ("cor","raio") escapam por acaso; em inglês, não. [NÃO VERIFICADO] — leitura do código.
4.3 labels=True passa pelo LaTeX, e troca a classe do vértice
[FONTE] graph.py:589-596 (o bloco de labels):
if labels:
self._labels = {v: MathTex(v, color=label_fill_color) for v in vertices}
...
if self._labels and vertex_type is Dot:
vertex_type = LabeledDot
Três coisas embutidas aí:
MathTex— precisa de LaTeX funcionando, e o nome do vértice é interpretado como matemática:Child_0vira "Child" com subscrito 0,ABvira dois itálicos multiplicados. Para rótulo em prosa passe um dict:labels={1: Text("API"), 2: Text("Banco")}. LaTeX quebrado é assunto demanim-text-latexemanim-troubleshooting.- A troca
Dot→LabeledDotsó acontece severtex_typeainda forDot. Passouvertex_type=Squarecomlabels=True? O rótulo entra emvertex_config["label"]e oSquarerecebe um kwarglabelque ele não conhece →TypeError. [NÃO VERIFICADO] label_fill_colordefault éBLACK— em fundo escuro, sobre oDotbranco, funciona. Em canvas branco (§4.5) você fica com rótulo preto sobre disco branco: legível, mas o disco some.
4.4 O raio do vértice rotulado é calculado, e varia por rótulo
[FONTE] LabeledDot.__init__ (mobject/geometry/arc.py:895-898):
if radius is None:
radius = buff + float(np.linalg.norm([rendered_label.width, rendered_label.height]) / 2)
Meia diagonal do rótulo, mais buff (default SMALL_BUFF = 0,1).
Consequência: "1" e "Greatgrandchild_0" viram círculos de tamanhos
muito diferentes, e o layout não sabe disso — ele posiciona centros, não
caixas. Rótulo longo em layout apertado se sobrepõe sem erro nenhum.
As duas saídas: encurte o rótulo (é o certo — o nome comprido vai numa legenda
ao lado), ou fixe vertex_config={"radius": 0.28} e aceite que o texto
transborda o disco.
Números de enquadramento, para decidir sem renderizar. Quadro do Manim: 14,222 × 8 unidades; a 1920×1080 isso é 135 px por unidade (1080/8).
| Coisa | Unidades | Pixels a 1080p |
|---|---|---|
Dot default (radius=0.08) [ÍNDICE] |
0,16 de diâmetro | 21,6 px — pequeno demais para projetor |
um Dot legível de longe |
0,18–0,28 de raio | 49–76 px de diâmetro |
caixa do grafo com layout_scale=2 (default) |
4 × 4 | 540 × 540 px |
| quadro inteiro | 14,222 × 8 | 1920 × 1080 |
Com o default, o grafo ocupa um quarto da altura e um sétimo da largura.
layout_scale=3 (≈ 810 px) é o valor que a própria doc usa nos exemplos
grandes.
4.5 Fundo branco: o grafo inteiro desaparece
[ÍNDICE] Dot(..., color=WHITE); Line herda o color default de
Mobject, que também é branco. Num tema de canvas claro
(--theme whiteboard, background_color = #FFFFFF, ou o CANVAS de um
tema.py), um Graph(V, E) cru sai invisível — sem erro, sem aviso.
Este é o defeito nº 1 de vídeo gerado por agente em tema claro, e a defesa é a mesma do resto do projeto: cor explícita, vinda do tema.
from tema import TINTA, ACENTO # nunca hex solto na cena
g = Graph(
V, E,
labels={v: Text(v, color=TINTA, font_size=22) for v in V},
vertex_config={"radius": 0.22, "color": ACENTO},
edge_config={"stroke_color": TINTA, "stroke_width": 3},
)
Quem manda em paleta, contraste e set_default é manim-color-theming;
quem manda no tema.py como contrato é manim-tema-projeto. Esta skill só
registra que Graph é um dos objetos que não têm cor própria e por isso
some.
5. Arestas
5.1 edge_config também tem duas caras — e mais uma terceira
[FONTE] graph.py:628-651. A regra de separação aqui não é "é um
vértice?", é "é uma tupla?":
default_tip_config = edge_config.pop("tip_config", {})
default_edge_config = {k: v for k, v in edge_config.items() if not isinstance(k, tuple)}
| Chave | Vira |
|---|---|
"tip_config" |
config da ponta de seta, para todas as arestas (só DiGraph usa) |
qualquer nome que não seja tupla ("stroke_width", "color") |
default de todas as arestas |
uma tupla (u, v) |
config só daquela aresta — e ela pode ter o seu próprio "tip_config" dentro |
Exemplo completo, copiado da doc do módulo (graph.py:1695-1725) e conferido
contra a assinatura:
edge_config = {
"stroke_width": 2,
"tip_config": {"tip_shape": ArrowSquareTip, "tip_length": 0.15},
(3, 4): {"color": RED, "tip_config": {"tip_length": 0.25, "tip_width": 0.25}},
}
g = DiGraph(vertices, edges, labels=True, layout="circular", edge_config=edge_config)
Efeito colateral real: o código faz edge_config[e].pop("tip_config", …) —
pop, não get. Ele modifica o dicionário que você passou. Reusar a mesma
constante EDGE_CONFIG em dois grafos dá dois resultados diferentes: no
segundo, o tip_config interno já foi removido. [NÃO VERIFICADO] — leitura
de graph.py:633-651. Passe uma cópia: edge_config=copy.deepcopy(EDGE_CONFIG).
5.2 O (u,v) × (v,u) que a documentação promete e o código não faz
A doc do Graph diz, textualmente ([FONTE] graph.py:1412-1416):
In
edge_config, edges can be passed in both directions: if(u, v)is an edge in the graph, both(u, v)as well as(v, u)can be used as keys.
O código não implementa isso. [FONTE] graph.py:643-651 itera sobre a lista
edges que você passou e testa if e in edge_config — comparação de tupla
crua. [HOJE] grep -n "v, u\|reversed\|sorted(\|frozenset" graph.py não
encontra normalização nenhuma no módulo inteiro (o único sorted( é de um
exemplo de docstring, linha 64).
A chave invertida cai no else e o seu estilo é descartado em silêncio —
ela nem entra em default_edge_config, porque tuplas são filtradas de lá.
O exemplo da própria documentação do ManimCE cai nessa armadilha.
LabeledModifiedGraph (graph.py:1418-1432) pinta três arestas de vermelho:
edges = [(1, 7), (1, 8), ..., (7, 2), (7, 4)] # graph.py:1424-1426
edge_config={(1, 7): {...}, (2, 7): {...}, (4, 7): {...}} # graph.py:1429-1431
(1,7) está na lista como (1,7) → funciona. (2,7) e (4,7) estão na lista
como (7,2) e (7,4) → ignoradas. [NÃO VERIFICADO] por render, mas o
caminho de código não deixa alternativa.
A regra que resolve, e serve para g.edges também: guarde as arestas numa
lista só, e indexe por ela.
ARESTAS = [(1, 7), (1, 8), (7, 2), (7, 4)]
CAMINHO = [(1, 7), (7, 4)] # escrito na mesma direção da lista
def aresta(g, u, v):
"""A Line de {u,v}, na direção em que ela foi registrada."""
return g.edges.get((u, v)) or g.edges[(v, u)] # KeyError claro se não existe
5.3 Ponta de seta: só DiGraph, e o truque do tamanho zero
[FONTE] graph.py:1787-1788 — só o DiGraph chama add_tip:
for (u, v), edge in self.edges.items():
edge.add_tip(**self._tip_config[(u, v)])
tip_config aceita o que Mobject.add_tip aceita: tip_shape (as 8 classes
Arrow*Tip de mobject/geometry, catálogo em manim-mobjects), tip_length,
tip_width.
O idioma que a doc do módulo ensina e vale ouro (graph.py:1733-1762):
para um grafo não dirigido cujas arestas devem parar na borda do
vértice (e não sumir sob ele), use um DiGraph com ponta de tamanho zero:
g = DiGraph(V, E, labels=True,
edge_config={"tip_config": {"tip_length": 0, "tip_width": 0}})
Isso resolve o caso "o rótulo é grande e a linha atravessa o texto", porque o
DiGraph mede a caixa delimitadora do vértice (§3.4, item 1), que com
LabeledDot inclui o rótulo.
5.4 Rótulo de aresta (peso): LabeledLine não serve aqui
A tentação é edge_type=LabeledLine, com edge_config={(1,2): {"label": "7"}}.
[ÍNDICE] a assinatura aceita:
LabeledLine(label, label_position=0.5, label_config=None, box_config=None, frame_config=None, *args, **kwargs).
Mas [FONTE] mobject/geometry/labeled.py:172-185: o rótulo é posicionado
uma vez, no __init__, com self.label.move_to(label_coords) e
self.add(self.label). Não há updater. Quando update_edges reescreve os
pontos da linha (§3.4), o rótulo fica onde estava. Num grafo cujos vértices
se movem, os pesos descolam das arestas. [NÃO VERIFICADO] — leitura.
O que fazer, e é mais barato do que parece: rótulo é um mobject fora do
grafo, preso ao meio da aresta por always_redraw.
peso = always_redraw(
lambda: Text("7", color=TINTA, font_size=20)
.move_to(aresta(g, 1, 2).get_center() + 0.22 * UP)
)
self.add(peso)
always_redraw e updaters em geral são de manim-updaters-valuetracker —
é lá que estão o custo e as armadilhas dele. Aqui fica só a razão de precisar
dele.
6. Layout
6.1 Os três jeitos de posicionar
[FONTE] _determine_graph_layout (graph.py:444-477) resolve nesta ordem:
if isinstance(layout, dict): return layout # 1. manual
elif layout in _layouts: ... # 2. nome de layout
else: layout(nx_graph, ...) # 3. LayoutFunction
6.2 Os 10 nomes, com o que cada um exige e quando explode
[FONTE] _layouts (graph.py:430-441) — 8 vêm do networkx 3.6.1, 2 são do
próprio Manim. A coluna "determinístico" é a que decide se você pode usar num
vídeo (§7).
layout= |
Implementação | Determinístico? | Exige | Explode quando |
|---|---|---|---|---|
"spring" (default) |
nx.layout.spring_layout |
NÃO [FONTE] @np_random_state("seed"), layout.py:451 |
— | — |
"random" |
_random_layout do Manim (graph.py:310), que embrulha nx.layout.random_layout |
NÃO [FONTE] @np_random_state(3), layout.py:63 |
— | dim=3 no layout_config — o código só trata 2D (graph.py:315) |
"circular" |
nx.layout.circular_layout |
sim | — | — |
"shell" |
nx.layout.shell_layout |
sim | — | — |
"spectral" |
nx.layout.spectral_layout |
sim | — | — |
"spiral" |
nx.layout.spiral_layout |
sim | — | — |
"kamada_kawai" |
nx.layout.kamada_kawai_layout |
sim | scipy (já é dependência do Manim) | grafo desconexo — resultado degenerado |
"planar" |
nx.layout.planar_layout |
sim | grafo planar | nx.NetworkXException: G is not planar. [FONTE] layout.py |
"tree" |
_tree_layout (graph.py:319) |
sim | root_vertex e que o grafo seja uma árvore |
ValueError: The tree layout requires the root_vertex parameter; ou ValueError: The tree layout must be used with trees (nx.is_tree falso) |
"partite" |
_partite_layout (graph.py:284) |
sim | partitions não vazio |
ValueError: The partite layout requires partitions parameter…; e KeyError se um vértice da partição não existe no grafo |
[ÍNDICE/FONTE] As assinaturas do networkx 3.6.1 que recebem os
layout_config (extraídas de networkx/drawing/layout.py):
spring_layout(G, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4,
weight="weight", scale=1, center=None, dim=2, seed=None, ...)
circular_layout(G, scale=1, center=None, dim=2, ...)
shell_layout(G, nlist=None, rotate=None, scale=1, center=None, dim=2, ...)
spectral_layout(G, weight="weight", scale=1, center=None, dim=2, ...)
spiral_layout(G, scale=1, center=None, dim=2, resolution=0.35, equidistant=False, ...)
kamada_kawai_layout(G, dist=None, pos=None, weight="weight", scale=1, center=None, dim=2, ...)
planar_layout(G, scale=1, center=None, dim=2, ...)
multipartite_layout(G, subset_key="subset", align="vertical", scale=1, center=None, ...)
Tudo o que estiver nessas assinaturas pode ir em layout_config. Exemplos
úteis: layout_config={"seed": 42} (§7), {"iterations": 200} para o spring
relaxar mais, {"nlist": [[1,2],[3,4,5]]} para escolher os anéis do shell,
{"rotate": 0} para o shell não girar.
6.3 layout_scale — o que ele faz e o que ele não faz
[FONTE] os layouts do networkx terminam em rescale_layout
(layout.py:1888-1894):
pos -= pos.mean(axis=0)
lim = np.abs(pos).max() # o MAIOR valor de TODOS os eixos
if lim > 0: pos *= scale / lim
Três leituras diretas disso:
- O escalonamento preserva a proporção.
layout_scale=2garante que a maior coordenada, em qualquer eixo, vale 2 — não que o grafo tenha 4×4. Uma rede larga e baixa continua larga e baixa, só que menor. layout_scalecomo tupla só funciona notree. A assinatura anunciafloat | tuple[float, float, float][ÍNDICE], e_tree_layout(graph.py:395-404) de fato trata tupla. Mas nos layouts do networkx o valor cai empos *= scale / lim→tuple / float→TypeError. [NÃO VERIFICADO], mas o caminho é aritmética de Python.layout_scaleé ignorado no layout manual — o dicionário volta cru (graph.py:454). É por isso que o §6.5 existe.
6.4 tree e partite: os dois que precisam de um segundo parâmetro
Graph(V, E, layout="tree", root_vertex="ROOT",
layout_config={"vertex_spacing": (0.6, 1.1)})
vertex_spacing=(dx, dy) é especialidade do _tree_layout
(graph.py:395-404): irmãos ficam a pelo menos dx de distância horizontal e
camadas vizinhas a dy na vertical — e ele sobrepõe o layout_scale, ou
seja, o tamanho final passa a depender de quantos nós a árvore tem. Árvore
grande + vertex_spacing = grafo maior que o quadro; a doc do Manim resolve
isso com MovingCameraScene e self.camera.auto_zoom(g, margin=1)
([ÍNDICE] MovingCamera.auto_zoom(mobjects, margin=0, only_mobjects_in_frame=False, animate=True)),
que é assunto de manim-camera-2d.
_tree_layout também aceita orientation ("down" default, qualquer outra
coisa inverte o sinal — graph.py:341).
Graph(V, E, layout="partite", partitions=[[1, 2], [3, 4, 5], [6]])
[FONTE] _partite_layout (graph.py:284-307) marca
nx_graph.nodes[v]["subset"] = i e chama nx.multipartite_layout, cujo default
é align="vertical" → colunas. Dois detalhes que a doc registra e o código
confirma:
- vértices que você não listou em partição nenhuma entram numa partição
extra, à direita (
graph.py:302-306); _partite_layoutescreve no grafo (nodes[v]["subset"]), violando o contrato do próprioLayoutFunction, que exige função pura (graph.py:41). Depois de um layout partite,g._graphcarrega atributossubset. [NÃO VERIFICADO] quanto a efeito colateral observável.- a checagem
if nx_graph.nodes[v] is None(graph.py:297-300) nunca é verdadeira: para um vértice ausente,nodes[v]já levantaKeyError. Ou seja, partição com nome errado dáKeyErrorcru, não a mensagem amigável.
Uma rede neural em camadas é exatamente isto — é o exemplo LinearNN da doc
(graph.py:1461-1486): uma partição por camada, layout_scale=3,
vertex_config={'radius': 0.20}.
6.5 Layout manual: pontos 3D, obrigatoriamente
POSICOES = {1: [-2, 0, 0], 2: [-1, 1, 0], 3: [0, 0, 0], 4: [1, 1, 0]}
g = Graph(V, E, layout=POSICOES)
[FONTE] graph.py:453-454: quando layout é dict, o código faz
return layout — sem passar pelo np.append(v, [0]) que os layouts
automáticos recebem (graph.py:466). Depois, change_layout faz
self[v].move_to(self._layout[v]).
Consequência: um dicionário com pontos 2D ({1: [0, 0]}) chega em
Mobject.move_to com shape (2,) e estoura no shift, com um
ValueError: operands could not be broadcast together with shapes (2,) (3,) —
um erro que não menciona grafo nenhum. [NÃO VERIFICADO] quanto ao texto
exato; o mecanismo (subtração numpy de shapes incompatíveis) é certo.
E layout_scale não faz nada aqui: as coordenadas do dicionário são
coordenadas de cena, diretas.
6.6 LayoutFunction própria
[ÍNDICE] o protocolo (manim.mobject.graph.LayoutFunction, fora do star
import):
def __call__(self, graph: NxGraph, scale: float | tuple = 2,
*args, **kwargs) -> dict[Hashable, Point3D]: ...
Contrato, do docstring (graph.py:39-41): função pura — não modifique o grafo
recebido. Ela recebe scale=layout_scale e o **layout_config inteiro.
def grade(graph, scale=2, colunas=4, **kwargs):
"""Arruma os vértices em grade, em ordem estável de nome."""
nos = sorted(graph)
linhas = (len(nos) + colunas - 1) // colunas
return {
no: scale * np.array([(i % colunas) - (colunas - 1) / 2,
-(i // colunas) + (linhas - 1) / 2,
0])
for i, no in enumerate(nos)
}
g = Graph(V, E, layout=grade, layout_config={"colunas": 4})
Devolva pontos 3D (o np.append só roda no ramo dos layouts nomeados,
graph.py:461-466; uma função própria entra pelo ramo do else, linha 469, e
o retorno vai cru para o move_to).
Se o objeto que você passou não for chamável, o except TypeError transforma
tudo em uma mensagem só (graph.py:471-476):
ValueError: The layout 'sprng' is neither a recognized layout, a layout function,
nor a vertex placement dictionary.
Ou seja: nome de layout digitado errado vira "não é função", não "nome inválido". Se você viu essa mensagem, confira primeiro a grafia contra a tabela do §6.2.
7. A armadilha-mor: spring e random mudam a cada render
Esta é a seção que salva um dia de trabalho. O layout default é "spring",
e ele é aleatório.
7.1 O mecanismo, em três saltos
- [FONTE]
networkx/drawing/layout.py:451—@np_random_state("seed")decoraspring_layout. Comseed=None, ele usa o estado global do numpy. (random_layoutidem,layout.py:63.) - [FONTE]
manim/scene/scene.py:180, 223-224— oScene.__init__faz:self.random_seed = random_seed if random_seed is not None else config.seed random.seed(self.random_seed) np.random.seed(self.random_seed) - [FONTE]
config.seednão tem valor nodefault.cfgdo ManimCE e [HOJE] não aparece nomanim.cfgdeste projeto (grep -n seed manim.cfg→ nada). Logoconfig.seed is None→np.random.seed(None)→ o numpy reembaralha a partir da entropia do sistema a cada processo.
Resultado: dois renders da mesma cena produzem grafos com vértices em lugares diferentes. Sem erro. Sem aviso.
7.2 Por que isso é FATAL numa cena em partes
O formato de manim-presentation-parts garante que o primeiro frame da
parte N+1 é, pixel a pixel, o último da parte N — porque é o mesmo código
rodando de novo, com skip_animations. Um layout aleatório quebra essa
garantia: cada parte é um processo diferente, cada processo sorteia outro
layout, e a emenda vira um salto do grafo inteiro.
E a métrica direcional de emenda (tinta que some) vai acusar centenas ou
milhares de pixels — muito acima do limiar de 400 px em 1920×1080. Se você
mediu uma emenda estourada numa cena com Graph, suspeite disto antes de
qualquer outra coisa.
O mesmo vale, em menor grau, para o cache de partial movies: o hash da chamada
de play muda a cada execução, então nada é reaproveitado
(manim-performance-cache).
7.3 As correções, em ordem de preferência
# 1. LOCAL e explícita — a melhor. Vai direto para o nx.spring_layout.
g = Graph(V, E, layout="spring", layout_config={"seed": 42})
# 2. Determinismo por construção: escolha um layout que não sorteia.
g = Graph(V, E, layout="circular") # ou kamada_kawai, shell, tree, partite…
# 3. Congele o resultado UMA vez e escreva o dicionário na cena.
# (rode uma vez com print(g._layout), cole o dict, use layout=POSICOES)
g = Graph(V, E, layout=POSICOES)
A opção 3 é a que o §1 recomenda para diagrama de aula: layout automático é ótimo para explorar, e ruim para entregar.
O que NÃO resolve: mx render não expõe --seed. [HOJE]
manimx/cli.py:457-476 lista as flags do subcomando render e --seed não
está lá; ela existe só no bin/manim (manim/cli/render/global_options.py:147-152).
Um config.seed = 42 no topo do módulo de cena funciona — mas é estado global,
e não protege quem importar a cena de outro jeito. Prefira layout_config.
8. change_layout — o que ele desfaz
[ÍNDICE]
GenericGraph.change_layout(self, layout="spring", layout_scale=2,
layout_config=None, partitions=None,
root_vertex=None) -> Graph
[FONTE] o corpo inteiro (graph.py:1256-1271) faz três coisas:
if partitions is not None and "partitions" not in layout_config:
layout_config["partitions"] = partitions
if root_vertex is not None and "root_vertex" not in layout_config:
layout_config["root_vertex"] = root_vertex
self._layout = _determine_graph_layout(self._graph, layout, layout_scale, layout_config)
for v in self.vertices:
self[v].move_to(self._layout[v])
Daí saem três armadilhas, todas de leitura direta:
8.1 Ele recentraliza na ORIGEM e joga fora shift/move_to/scale
Os layouts do networkx centram em center=None → origem, e _tree_layout
subtrai o próprio centro. self._layout nunca soube que você mexeu no grafo:
[HOJE] grep -n "_layout\b" graph.py mostra que ele só é escrito em
4 lugares — change_layout (1262), _add_created_vertex (755),
_remove_vertex (964, um pop) e o __init__ via change_layout (619).
shift, scale e move_to não tocam nele.
Então:
g = Graph(V, E, layout="circular").scale(1.4).to_edge(LEFT)
self.play(g.animate.change_layout("kamada_kawai"))
# → o grafo PULA para o centro do quadro, com as posições em escala 2.
# (os Dots continuam 1,4× maiores — só as POSIÇÕES voltaram)
A correção: reposicione depois, na mesma animação.
alvo = g.copy().change_layout("kamada_kawai").scale(1.4).to_edge(LEFT)
self.play(Transform(g, alvo))
…ou passe center pelo layout_config, que os layouts do networkx aceitam
(layout.py, coluna center=None em todas as assinaturas do §6.2) — lembrando
que ele é 2D ali, porque o np.append(v,[0]) vem depois. [NÃO VERIFICADO]
para tree/partite, que não têm center na assinatura.
8.2 Ele MUTA o layout_config que você passou
layout_config não é copiado; layout_config["partitions"] = … escreve no seu
dicionário. Reusar uma constante de classe entre duas chamadas contamina a
segunda:
LAYOUT_CONFIG = {"vertex_spacing": (0.5, 1)}
g.change_layout("tree", root_vertex="ROOT", layout_config=LAYOUT_CONFIG)
# LAYOUT_CONFIG agora é {"vertex_spacing": (0.5,1), "root_vertex": "ROOT"}
g.change_layout("circular", layout_config=LAYOUT_CONFIG)
# → nx.circular_layout(G, scale=2, vertex_spacing=…, root_vertex=…) → TypeError
[NÃO VERIFICADO] por execução; a mutação está em graph.py:1258-1261, sem
cópia. Passe layout_config=dict(LAYOUT_CONFIG).
8.3 .animate.change_layout(...) funciona, e por quê
change_layout não tem override_animate, então .animate cai no
caminho genérico: _AnimationBuilder faz generate_target() (uma cópia
profunda do grafo), aplica o método no alvo e monta um _MethodAnimation
(família Transform). Como só as posições mudaram e a contagem de submobjects
é a mesma, a interpolação é bem-comportada — é o exemplo oficial
(graph.py:1241-1255).
Durante essa animação o updater do grafo fica suspenso
([ÍNDICE] Animation.__init__(..., suspend_mobject_updating: bool = True),
animation/animation.py:137), e as arestas interpolam como pontos. Isso é
correto para troca de layout — e é a razão de a aresta não "chicotear".
9. Mutação animada: vértices e arestas entrando e saindo
9.1 As quatro operações, com e sem animação
[FONTE] add_vertices, remove_vertices, add_edges e remove_edges
têm cada uma um par decorado com @override_animate (graph.py:923, 1004, 1128, 1183). Ou seja, g.animate.add_vertices(...) não é um Transform —
é um AnimationGroup construído à mão.
# imediato
g.add_vertices(5, 6, positions={5: [2, 1, 0], 6: [2, -1, 0]},
vertex_config={"radius": 0.2, "color": ACENTO})
g.add_edges((1, 5), (5, 6))
g.remove_edges((1, 2))
g.remove_vertices(3)
# animado
self.play(g.animate.add_vertices(5, positions={5: [2, 1, 0]}))
self.play(g.animate.add_edges((1, 5)))
self.play(g.animate.remove_vertices(3))
A animação usada em cada caso (graph.py:927, 1008, 1131, 1187):
| Operação | Animação default | Como trocar |
|---|---|---|
add_vertices |
Create |
anim_args={"animation": FadeIn} |
add_edges |
Create |
idem |
remove_vertices |
Uncreate |
anim_args={"animation": FadeOut} |
remove_edges |
Uncreate |
idem |
anim_args também carrega run_time, rate_func, lag_ratio — tudo que
manim-composicao-ritmo cobre:
self.play(g.animate.add_edges((1, 5), (5, 6),
anim_args={"animation": FadeIn, "run_time": 0.8}))
9.2 Encadeamento com .animate é proibido aqui
[FONTE] mobject/mobject.py:3443-3446:
if (self.is_chaining and has_overridden_animation) or self.overridden_animation:
raise NotImplementedError(
"Method chaining is currently not supported for overridden animations")
Então as duas formas abaixo levantam NotImplementedError:
g.animate.add_vertices(5).shift(UP) # ✗
g.animate.shift(UP).add_vertices(5) # ✗
Separe em dois play, ou em duas animações dentro do mesmo play
(self.play(g.animate.add_vertices(5), outra_coisa.animate.shift(UP))).
9.3 add_edges cria vértices faltantes — no centro do grafo
[FONTE] graph.py:1044 (_add_edge) e graph.py:707-708 (_create_vertex):
added_mobjects = [self._add_vertex(v) for v in edge if v not in self.vertices]
...
np_position = self.get_center() if position is None else np.asarray(position)
g.add_edges((1, 99)) com o vértice 99 inexistente cria o 99 — empilhado no
centro geométrico do grafo, com config default. Numa árvore que cresce, todos
os filhos novos nascem no mesmo ponto e ficam sobrepostos até o próximo
change_layout.
É exatamente o que o exemplo LargeTreeGeneration da doc faz de propósito
(graph.py:1522-1560): cria com positions={k: g.vertices[pai].get_center() + 0.1*DOWN}
e só depois anima g.animate.change_layout("tree", root_vertex="ROOT", …).
Nascer no pai e depois abrir a árvore é o idioma; nascer no centro é o
default e quase nunca é o que você quer.
9.4 remove_vertices leva as arestas incidentes junto
[FONTE] graph.py:969-976 — o _remove_vertex recolhe
[e for e in self.edges if vertex in e] e devolve tudo num VGroup. O
docstring traz o retorno literal:
>>> G = Graph([1, 2, 3], [(1, 2), (2, 3)])
>>> removed = G.remove_vertices(2, 3); removed
VGroup(Line, Line, Dot, Dot)
Ou seja: as duas linhas saem junto com os dois pontos, e o Uncreate do
.animate cai sobre os quatro. É o comportamento certo — só não conte com
"remover um vértice remove um mobject".
9.5 Uma assimetria observada, sem consequência conhecida
[FONTE] três dos quatro AnimationGroup gerados passam group=self
(graph.py:938, 1012, 1135); o de _remove_edges_animation (graph.py:1190)
não passa. [NÃO VERIFICADO] que efeito isso tem — provavelmente nenhum
visível, já que as arestas já foram destacadas do grafo antes da animação
começar. Registrado para quem for depurar um remove_edges que se comporta
diferente dos outros três.
10. Destacar um caminho
Esta é a coisa que mais se pede a um grafo em aula: "mostra o caminho de A até B". São quatro peças.
10.1 Achar o caminho — o networkx já está instalado
[HOJE] networkx>=2.6 é dependência declarada do ManimCE
(manim-*.dist-info/METADATA), e a versão instalada aqui é 3.6.1. Não
precisa instalar nada, e g._graph já é o objeto que o networkx entende.
import networkx as nx
caminho = nx.shortest_path(g._graph, 1, 6) # [1, 3, 5, 6]
pares = list(zip(caminho, caminho[1:])) # [(1,3), (3,5), (5,6)]
arestas = [aresta(g, u, v) for u, v in pares] # o helper do §5.2 — indispensável
O helper do §5.2 não é opcional. nx.shortest_path devolve os vértices na
ordem do percurso; g.edges está indexado pela tupla como você escreveu na
lista de arestas. Num Graph não dirigido, metade dos pares vem invertida e
g[(u, v)] levanta ValueError (§3.2).
10.2 Recolorir — o mais legível, e o que fica na tela
self.play(*[a.animate.set_stroke(ACENTO, width=6) for a in arestas],
*[g[v].animate.set_color(ACENTO) for v in caminho],
lag_ratio=0.15)
lag_ratio num play com várias animações é de manim-composicao-ritmo.
Para um frame de repouso num slide, este é o resultado que você quer: o
caminho permanece destacado enquanto o professor fala.
10.3 Um ponto correndo pela rede
bolinha = Dot(color=ACENTO, radius=0.12).move_to(g[caminho[0]])
self.add(bolinha)
self.play(Succession(*[MoveAlongPath(bolinha, a) for a in arestas]))
MoveAlongPath percorre a Line na direção em que ela foi construída, e o
helper do §5.2 devolve metade das arestas invertidas — nessas, o ponto anda
para trás. Não tente corrigir girando a aresta: construa a trajetória na ordem
do caminho, com uma Line descartável.
trilhos = [Line(g[u].get_center(), g[v].get_center()) for u, v in pares]
self.play(Succession(*[MoveAlongPath(bolinha, t) for t in trilhos]))
Succession e MoveAlongPath são de manim-composicao-ritmo e
manim-animations.
10.4 ShowPassingFlash numa aresta desmonta o grafo
Este é o defeito silencioso mais caro desta skill, e ele só aparece depois.
[FONTE] animation/indication.py:308-312:
super().__init__(mobject, remover=True, introducer=True, **kwargs)
remover=True → ao terminar, a animação chama scene.remove(a_aresta).
E [FONTE] Scene.remove → restructure_mobjects, cujo próprio docstring
(scene/scene.py:698-706) diz o que faz:
If your scene has a Group(), and you removed a mobject from the Group, this dissolves the group and puts the rest of the mobjects directly in self.mobjects.
O grafo é um VMobject com submobjects. Remover uma aresta da cena
dissolve o grafo: ele deixa de estar em self.mobjects e no lugar dele
entram os seus filhos, soltos.
O estrago aparece na animação seguinte: [FONTE] Scene.update_mobjects
(scene/scene.py:392-393) é for mobj in self.mobjects: mobj.update(dt) — só
os mobjects de topo. Com o grafo fora dessa lista, update_edges nunca mais
roda, e as arestas param de seguir os vértices. Nada dá erro; o vídeo sai
com as linhas presas no ar.
A correção é uma palavra: copy().
self.play(*[ShowPassingFlash(a.copy().set_stroke(ACENTO, width=8), time_width=0.5)
for a in arestas])
A mesma regra vale para qualquer animação remover=True aplicada a uma peça
do grafo: FadeOut(g[3]), Uncreate(g[(1,2)]), ShowPassingFlash. Se você
quer tirar a peça, use a API do grafo — g.animate.remove_vertices(3),
g.animate.remove_edges((1,2)) — que destaca o mobject antes de animar
(graph.py:1010, 1189) e por isso não dissolve nada. Se você quer só
destacar, anime uma cópia.
Animações que não removem e são seguras direto na peça: Indicate,
Wiggle, Circumscribe, Flash, .animate.set_color(...), .animate.scale(...).
O exemplo MovingVertices da própria doc usa Wiggle(g[(1, 2)])
(graph.py:1367).
11. O custo: o updater derruba o cache de frame estático
[FONTE] Scene.get_moving_mobjects (scene/scene.py:899-946):
mobjects = self.get_mobject_family_members()
for i, mob in enumerate(mobjects):
update_possibilities = [
mob in animation_mobjects,
len(mob.get_family_updaters()) > 0,
mob in self.foreground_mobjects,
]
if any(update_possibilities):
return mobjects[i:] # <-- daqui para a FRENTE, tudo é "moving"
Leia a linha do return. Assim que a varredura encontra um mobject com
updater, tudo o que vem depois dele na ordem da cena é declarado móvel e sai
do cache de frame estático — precisa ser rasterizado de novo a cada frame.
Um Graph sempre tem updater (§3.4). Logo:
| Decisão | Efeito |
|---|---|
self.add(grafo) antes do plano de fundo, do título e do rodapé |
o fundo inteiro é re-rasterizado 60×/s pelo resto da cena |
self.add(fundo, titulo, rodape) e depois self.add(grafo) |
só o grafo é móvel |
grafo.clear_updaters() quando ele parar de se mexer |
o grafo volta a ser estático |
A regra: o grafo entra por último, e quando ele parar de andar,
g.clear_updaters(). Depois disso, mover um vértice deixa a aresta para trás —
que é exatamente o que você quer num diagrama parado.
O tamanho também conta: um Graph com V vértices e A arestas tem
V + A submobjects diretos (mais os rótulos, que são filhos dos
LabeledDot). Um grafo de 30 nós e 60 arestas é um VMobject de ~90 filhos
sendo rasterizado a cada frame. Escolher Dot em vez de LabeledDot corta os
rótulos, que são MathTex — os mais caros da lista.
Cache de partial movies, max_files_cached e o que o hash enxerga são de
manim-performance-cache; codec e GPU são de manim-gpu-encoding.
12. from_networkx e o resto do networkx
12.1 A ponte
[ÍNDICE] GenericGraph.from_networkx(nxgraph, **kwargs) — classmethod.
[FONTE] o corpo é uma linha (graph.py:1226):
return cls(list(nxgraph.nodes), list(nxgraph.edges), **kwargs)
Ou seja: from_networkx não herda nada além de nós e arestas. Pesos,
atributos de nó, cores guardadas no grafo do networkx — tudo se perde. Se você
tem atributos, leia-os você mesmo e converta em vertex_config/edge_config:
import networkx as nx
nxg = nx.erdos_renyi_graph(14, 0.5, seed=7) # seed! §7
g = Graph.from_networkx(nxg, layout="spring", layout_scale=3.5,
layout_config={"seed": 7},
vertex_config={"radius": 0.16, "color": ACENTO},
edge_config={"stroke_color": TINTA, "stroke_width": 2})
Graph.from_networkx devolve Graph; DiGraph.from_networkx devolve
DiGraph (é cls(...)). Passar um nx.DiGraph para Graph.from_networkx
funciona e perde a direção — as arestas viram um nx.Graph interno.
12.2 O que o networkx resolve e você não deveria reimplementar
Com g._graph na mão (ou o nxg original):
nx.shortest_path(G, a, b) # caminho mínimo → lista de vértices
nx.shortest_path_length(G, a, b)
nx.dijkstra_path(G, a, b, weight="peso")
nx.bfs_edges(G, raiz) / nx.dfs_edges(G, raiz) # a ORDEM da travessia
nx.topological_sort(G) # só DiGraph acíclico
nx.minimum_spanning_edges(G)
nx.connected_components(G)
nx.degree(G) / nx.is_tree(G) / nx.check_planarity(G)
nx.bfs_edges/dfs_edges são particularmente úteis em aula: eles dão a
sequência de arestas na ordem em que o algoritmo as descobre, que é exatamente
a ordem de um Succession (manim-composicao-ritmo) — a animação vira uma
tradução direta do algoritmo.
12.3 Quando usar só o layout do networkx, sem Graph
Se você quer os Rectangle rotulados do §1 mas não quer decidir posição na
mão, chame o layout direto e posicione os seus próprios mobjects:
import networkx as nx
pos = nx.spring_layout(nxg, scale=3, seed=42) # {no: array([x, y])}
for nome, caixa in caixas.items():
x, y = pos[nome]
caixa.move_to([x, y, 0]) # o 3º eixo é seu
Você fica com layout automático, formas próprias e nenhum updater — o melhor dos dois lados quando o grafo não muda.
13. Legibilidade: o que decidir antes de renderizar
Um grafo bonito na documentação do Manim (fundo escuro, 8 nós, tela cheia) vira ilegível num slide (canvas claro, com título e rodapé, projetado a 6 m). A tabela abaixo é o mínimo para não descobrir isso depois do render.
| Decisão | Valor de partida | Por quê |
|---|---|---|
layout_scale |
3 com o grafo sozinho; 2 se dividir o palco | 3 → caixa de 6 unidades ≈ 810 px; ainda cabe em 8 de altura com folga para título |
| raio do vértice | vertex_config={"radius": 0.20} |
o default 0,08 dá 21,6 px de diâmetro (§4.4) |
stroke_width da aresta |
2–3 | o default do VMobject é 4 [ÍNDICE]; num grafo denso vira mancha |
| rótulo | labels={v: Text(str(v), font_size=20, color=TINTA)} |
labels=True passa pelo MathTex (§4.3) e italiza tudo |
| cor | sempre explícita | branco no branco (§4.5) |
| número de nós | ≤ 12 numa tela cheia | acima disso os rótulos se tocam, e o layout não sabe do tamanho deles (§4.4) |
| direção | DiGraph só se a direção for o assunto |
a ponta de seta come ~0,3 unidade de cada aresta e polui grafo denso |
Caber ou não caber na tela, margem, is_off_screen() e as réguas
(FullScreenRectangle) são de manim-layout-posicionamento. Contraste e
paleta são de manim-color-theming.
14. Uma cena de referência, comentada
Não foi renderizada. Toda assinatura usada está conferida no índice ou no fonte, com a seção que a justifica ao lado.
"""Rede de serviços: quem chama quem, e o caminho de uma requisição."""
import networkx as nx
from manim import *
from tema import CANVAS, TINTA, ACENTO, T_LEGENDA # manim-tema-projeto
NOS = ["cliente", "api", "fila", "worker", "banco"]
ARESTAS = [("cliente", "api"), ("api", "fila"), ("fila", "worker"),
("worker", "banco"), ("api", "banco")] # a ORDEM é o índice (§5.2)
POSICOES = { # §7.3 opção 2/3: determinístico
"cliente": [-5.0, 0.0, 0],
"api": [-2.2, 0.0, 0],
"fila": [ 0.6, 1.4, 0],
"worker": [ 3.4, 1.4, 0],
"banco": [ 3.4, -1.4, 0],
}
def aresta(g, u, v):
"""A Line de (u,v) na direção em que ela foi registrada — §5.2."""
return g.edges.get((u, v)) or g.edges[(v, u)]
class RedeDeServicos(Scene):
def construct(self):
self.camera.background_color = CANVAS
g = DiGraph( # §5.3: direção É o assunto
NOS, ARESTAS,
layout=POSICOES, # §6.5: pontos 3D
labels={v: Text(v, color=TINTA, font_size=20) for v in NOS}, # §4.3
vertex_config={"radius": 0.42, "color": ACENTO,
"fill_opacity": 1.0}, # §4.5: cor explícita
edge_config={"stroke_color": TINTA, "stroke_width": 3,
"tip_config": {"tip_length": 0.22}},
)
# §11: o grafo entra DEPOIS do cenário fixo, e é o único móvel.
# OBRIGATÓRIO, e é o defeito mais caro desta skill (§10.4): sem esta
# linha o que entra em scene.mobjects são os Dot e as Line SOLTOS —
# `Animation._setup_scene` (animation.py:257-261) adiciona o mobject da
# ANIMAÇÃO, não o pai. E `Scene.update_mobjects` (scene.py:392-393) só
# percorre o topo, então `update_edges` NUNCA roda e as arestas
# congelam, sem erro.
self.add(g)
for v in NOS: # invisíveis até o LaggedStart abaixo
g.vertices[v].set_opacity(0)
self.play(LaggedStart(*[FadeIn(g.vertices[v]) for v in NOS], lag_ratio=0.12))
self.play(LaggedStart(*[Create(aresta(g, u, v)) for u, v in ARESTAS],
lag_ratio=0.10))
self.wait(0.4)
# o caminho da requisição — §10.1
caminho = nx.shortest_path(g._graph, "cliente", "banco")
pares = list(zip(caminho, caminho[1:]))
# §10.4: pisca uma CÓPIA, nunca a aresta do grafo
self.play(*[ShowPassingFlash(aresta(g, u, v).copy().set_stroke(ACENTO, width=9),
time_width=0.6, run_time=1.2)
for u, v in pares])
# §10.2: o destaque que FICA, para o frame de repouso
# §10.2: `set_color` desce na FAMÍLIA (vectorized_mobject.py:473-476), e
# com `labels` o vértice virou LabeledDot (graph.py:594-595) — o Text é
# submobject dele. `g[v].animate.set_color(...)` repintaria o RÓTULO da
# mesma cor do disco e ele sumiria. Pinte só o disco: submobject 0.
self.play(*[aresta(g, u, v).animate.set_stroke(ACENTO, width=5) for u, v in pares],
*[g[v][0].animate.set_color(ACENTO) for v in caminho],
run_time=0.6)
# §11: acabou o movimento — o grafo volta a ser estático.
# Só faz sentido porque o `self.add(g)` lá em cima pôs o updater
# `update_edges` para rodar de verdade; sem ele isto seria no-op.
g.clear_updaters()
self.wait(0.4)
O que este exemplo não faz de propósito: não usa labels=True (§4.3), não
usa layout="spring" (§7), não pisca a aresta original (§10.4), não põe título
dentro do vídeo (isso é do slide — manim-presentation-parts).
15. Conferir sem renderizar
Três coisas dão para checar em milissegundos, e cobrem os erros mais caros desta skill. [NÃO VERIFICADO] — os comandos abaixo foram escritos, não executados nesta sessão.
1. Layout não determinístico numa cena que vai virar vídeo:
grep -nE 'layout\s*=\s*"(spring|random)"' cena.py \
| grep -v 'seed' \
&& echo "^^ layout aleatório sem seed — leia a §7"
Um Graph(V, E) sem layout= também cai aqui (o default é "spring"):
grep -nE '\b(Di)?Graph\(' cena.py | grep -v 'layout'
2. Aresta destacada sem .copy() — o §10.4:
grep -nE '(ShowPassingFlash|FadeOut|Uncreate)\([^)]*(g\.edges|g\[\()' cena.py \
| grep -v '\.copy()' \
&& echo "^^ animação remover=True sobre peça do grafo — dissolve o grafo"
3. Chave de edge_config que não existe na lista de arestas — o §5.2.
Um script de 12 linhas com ast, sem importar o Manim:
import ast, sys
fonte = ast.parse(open(sys.argv[1]).read())
tuplas = {
tuple(ast.literal_eval(e) for e in no.elts)
for no in ast.walk(fonte)
if isinstance(no, ast.Tuple) and len(no.elts) == 2
and all(isinstance(e, ast.Constant) for e in no.elts)
}
# arestas e chaves de edge_config saem do MESMO conjunto de tuplas;
# o que interessa é achar par simétrico: (a,b) e (b,a) no mesmo arquivo.
invertidas = {(a, b) for (a, b) in tuplas if (b, a) in tuplas and a != b}
if invertidas:
print("tuplas presentes nos DOIS sentidos (§5.2):", sorted(invertidas))
O ciclo completo de verificação — renderizar rápido, olhar o PNG, corrigir —
é de manim-verificacao-visual. Os três defeitos que esta skill produz e que
não dão erro nenhum são: grafo branco no fundo branco (§4.5), layout que
muda entre partes (§7.2) e arestas congeladas depois de um flash (§10.4). Os
três só aparecem na imagem.
16. On
Truncated - read the full file at https://github.com/frederico-kluser/manim-agent-skill/blob/3c231d4c620a954ae9b68e1eef463a06a80ec197/.claude/skills/manim-grafos-redes/SKILL.md.