Move tutte_embedding to lib, add bash completion, and fix NixOS setup

- Replace iterative tutte_embedding in lib with numpy direct-solve version from example.py
- Import tutte_embedding into example.py from lib instead of defining it locally
- Fix g._embedding -> g.get_embedding() in outer_face
- Add bash completion to run.sh alongside existing zsh completion
- Use nix-shell -p gcc for plantri build step on NixOS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:02:22 -04:00
parent 52ba816a90
commit 03f92494f1
3 changed files with 87 additions and 93 deletions
+9 -43
View File
@@ -5,8 +5,10 @@ import json
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any, cast, TypedDict, Literal from typing import Any, cast, TypedDict, Literal
import math
from sage.all import graphs, Graph, save, load # type: ignore[attr-defined] # pylint: disable=no-name-in-module from sage.all import graphs, Graph, save, load # type: ignore[attr-defined] # pylint: disable=no-name-in-module
from lib.tutte_embedding import tutte_embedding
DIR = Path(__file__).parent.parent DIR = Path(__file__).parent.parent
PALETTE = ['red', 'blue', 'green', 'yellow'] PALETTE = ['red', 'blue', 'green', 'yellow']
@@ -87,7 +89,9 @@ def save_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, Verte
def outer_face(g: Graph) -> list[Any]: def outer_face(g: Graph) -> list[Any]:
"""Return the vertices of the outer (unbounded) face of g using its planar embedding and positions.""" """Return the vertices of the outer (unbounded) face of g using its planar embedding and positions."""
pos = g.get_pos() pos = g.get_pos()
embedding = g._embedding embedding = g.get_embedding()
if not pos or not embedding:
raise Exception("Position and embedding required to find outer face")
visited: set[tuple[Any, Any]] = set() visited: set[tuple[Any, Any]] = set()
faces: list[list[Any]] = [] faces: list[list[Any]] = []
@@ -115,50 +119,12 @@ def outer_face(g: Graph) -> list[Any]:
return min(faces, key=signed_area) return min(faces, key=signed_area)
def tutte_embedding(g: Graph, outer: list[Any]) -> dict[Any, tuple[float, float]]:
"""Compute a Tutte embedding fixing outer on a convex polygon, solving for inner vertices."""
import math
import numpy as np
outer_set = set(outer)
inner = [v for v in g.vertices() if v not in outer_set]
pos: dict[Any, tuple[float, float]] = {}
for i, v in enumerate(outer):
angle = 2 * math.pi * i / len(outer)
pos[v] = (math.cos(angle), math.sin(angle))
if not inner:
return pos
inner_idx = {v: i for i, v in enumerate(inner)}
n = len(inner)
A = np.zeros((n, n))
bx = np.zeros(n)
by = np.zeros(n)
for i, v in enumerate(inner):
neighbors = g.neighbors(v)
deg = len(neighbors)
A[i, i] = 1.0
for w in neighbors:
if w in inner_idx:
A[i, inner_idx[w]] = -1.0 / deg
else:
bx[i] += pos[w][0] / deg
by[i] += pos[w][1] / deg
x = np.linalg.solve(A, bx)
y = np.linalg.solve(A, by)
for i, v in enumerate(inner):
pos[v] = (float(x[i]), float(y[i]))
return pos
def get_embedding_from_pos(g: Graph) -> dict[Any, list[Any]]: def get_embedding_from_pos(g: Graph) -> dict[Any, list[Any]]:
"""Compute a combinatorial planar embedding by sorting each vertex's neighbors by angle.""" """Compute a combinatorial planar embedding by sorting each vertex's neighbors by angle."""
import math pos_or_none = g.get_pos()
pos = g.get_pos() if pos_or_none is None:
raise Exception("Cannot get embedding without position")
pos: dict[Any, Any] = pos_or_none
return { return {
v: sorted(g.neighbors(v), key=lambda w: math.atan2(pos[w][1] - pos[v][1], pos[w][0] - pos[v][0])) v: sorted(g.neighbors(v), key=lambda w: math.atan2(pos[w][1] - pos[v][1], pos[w][0] - pos[v][0]))
for v in g.vertices() for v in g.vertices()
+35 -47
View File
@@ -1,57 +1,45 @@
"""Module for performing tutte embeddings""" """Module for performing tutte embeddings"""
from typing import Iterable, cast, Any import math
from sage.all import vector, Graph, cos, pi, sin # type: ignore from typing import Any
import numpy as np
from sage.all import Graph # type: ignore
def create_tutte_embedding( def tutte_embedding(g: Graph, outer: list[Any]) -> dict[Any, tuple[float, float]]:
graph: Graph, """Compute a Tutte embedding fixing outer on a convex polygon, solving for inner vertices."""
outer_face: list[Any],
max_iter: int=50,
set_pos: bool = True) -> dict[Any, Any]:
"""
Performs a tutte force embedding with a prescribed outer face on
a given sage graph.
For a description on tutte embeddings, see the video [here](\ outer_set = set(outer)
https://www.youtube.com/watch?v=mEzPPMhR8XE). inner = [v for v in g.vertices() if v not in outer_set]
"""
radius = 1000
pos: dict[Any, Any] = {}
num_outer_points = len(outer_face)
for i, v in enumerate(outer_face):
pos[v] = (
radius * cos((i / num_outer_points) * 2 * pi),
radius * sin((i / num_outer_points) * 2 * pi)
)
# start off setting all other points to (0, 0) pos: dict[Any, tuple[float, float]] = {}
num_inner_points = cast(int, graph.order()) - num_outer_points for i, v in enumerate(outer):
angle = 2 * math.pi * i / len(outer)
pos[v] = (math.cos(angle), math.sin(angle))
for i, v in enumerate(cast(Iterable[Any], graph.vertices())): # type: ignore if not inner:
if v in pos: return pos
continue
pos[v] = (
radius/3 * cos((i / num_inner_points) * 2 * pi),
radius/3 * sin((i / num_inner_points) * 2 * pi)
)
i = 0 inner_idx = {v: i for i, v in enumerate(inner)}
while i < max_iter: n = len(inner)
for v in cast(Iterable[Any], graph.vertices()): # type: ignore A = np.zeros((n, n))
if v in outer_face: bx = np.zeros(n)
continue by = np.zeros(n)
neighbors = cast(list[Any], graph.neighbors(v)) # type: ignore
deg = len(neighbors)
x_desired = sum(map(lambda n: pos[n][0], neighbors)) / deg
y_desired = sum(map(lambda n: pos[n][1], neighbors)) / deg
pos[v] = tuple(
vector(pos[v]) - vector([ # type: ignore
pos[v][0] - x_desired, pos[v][1] - y_desired
])
)
i += 1
if set_pos: for i, v in enumerate(inner):
graph.set_pos(pos) # type: ignore neighbors = g.neighbors(v)
deg = len(neighbors)
A[i, i] = 1.0
for w in neighbors:
if w in inner_idx:
A[i, inner_idx[w]] = -1.0 / deg
else:
bx[i] += pos[w][0] / deg
by[i] += pos[w][1] / deg
x = np.linalg.solve(A, bx)
y = np.linalg.solve(A, by)
for i, v in enumerate(inner):
pos[v] = (float(x[i]), float(y[i]))
return pos return pos
+43 -3
View File
@@ -55,7 +55,11 @@ with open("$SCRIPT_DIR/.vscode/settings.json", "w") as f:
f.write("\n") f.write("\n")
EOF EOF
make -C "$SCRIPT_DIR/plantri" if command -v nix-shell &>/dev/null; then
nix-shell -p gcc --run "make -C \"$SCRIPT_DIR/plantri\""
else
make -C "$SCRIPT_DIR/plantri"
fi
"$sage_python_path" -m venv "$SCRIPT_DIR/.venv" "$sage_python_path" -m venv "$SCRIPT_DIR/.venv"
"$VENV_PYTHON" -m pip install -r "$SCRIPT_DIR/requirements.txt" "$VENV_PYTHON" -m pip install -r "$SCRIPT_DIR/requirements.txt"
@@ -116,7 +120,7 @@ _run_sh() {
_files _files
;; ;;
completion) completion)
_values 'shell' 'zsh' _values 'shell' 'zsh' 'bash'
;; ;;
esac esac
;; ;;
@@ -124,10 +128,46 @@ _run_sh() {
} }
compdef _run_sh run.sh compdef _run_sh run.sh
EOF
;;
bash)
cat <<'EOF'
_run_sh() {
local cur prev words cword
_init_completion || return
local subcmds='init_paper setup sage lint completion'
if [[ $cword -eq 1 ]]; then
COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )
return
fi
case ${words[1]} in
init_paper)
# free-form paper title, no completion
;;
setup)
case $((cword - 1)) in
1) COMPREPLY=( $(compgen -f -- "$cur") ) ;;
2) COMPREPLY=( $(compgen -d -- "$cur") ) ;;
3) COMPREPLY=() ;; # system name, free-form
esac
;;
sage|lint)
COMPREPLY=( $(compgen -f -- "$cur") )
;;
completion)
COMPREPLY=( $(compgen -W 'zsh bash' -- "$cur") )
;;
esac
}
complete -F _run_sh run.sh
EOF EOF
;; ;;
*) *)
echo "Unsupported shell: $shell. Supported: zsh" >&2 echo "Unsupported shell: $shell. Supported: zsh bash" >&2
exit 1 exit 1
;; ;;
esac esac