From 03f92494f1136dec684c31e47cf733d3e776048c Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 21 Apr 2026 21:02:22 -0400 Subject: [PATCH] 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 --- colored_pentagon_reduction/example.py | 52 +++-------------- lib/tutte_embedding.py | 82 ++++++++++++--------------- run.sh | 46 ++++++++++++++- 3 files changed, 87 insertions(+), 93 deletions(-) diff --git a/colored_pentagon_reduction/example.py b/colored_pentagon_reduction/example.py index 2b23cdd..8ac83d1 100644 --- a/colored_pentagon_reduction/example.py +++ b/colored_pentagon_reduction/example.py @@ -5,8 +5,10 @@ import json from collections import defaultdict from pathlib import Path 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 lib.tutte_embedding import tutte_embedding DIR = Path(__file__).parent.parent 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]: """Return the vertices of the outer (unbounded) face of g using its planar embedding and positions.""" 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() faces: list[list[Any]] = [] @@ -115,50 +119,12 @@ def outer_face(g: Graph) -> list[Any]: 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]]: """Compute a combinatorial planar embedding by sorting each vertex's neighbors by angle.""" - import math - pos = g.get_pos() + pos_or_none = g.get_pos() + if pos_or_none is None: + raise Exception("Cannot get embedding without position") + pos: dict[Any, Any] = pos_or_none return { 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() diff --git a/lib/tutte_embedding.py b/lib/tutte_embedding.py index a3be232..d2391bc 100644 --- a/lib/tutte_embedding.py +++ b/lib/tutte_embedding.py @@ -1,57 +1,45 @@ """Module for performing tutte embeddings""" -from typing import Iterable, cast, Any -from sage.all import vector, Graph, cos, pi, sin # type: ignore +import math +from typing import Any + +import numpy as np +from sage.all import Graph # type: ignore -def create_tutte_embedding( - graph: Graph, - 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. +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.""" - For a description on tutte embeddings, see the video [here](\ - https://www.youtube.com/watch?v=mEzPPMhR8XE). - """ - 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) - ) + outer_set = set(outer) + inner = [v for v in g.vertices() if v not in outer_set] - # start off setting all other points to (0, 0) - num_inner_points = cast(int, graph.order()) - num_outer_points + 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)) - for i, v in enumerate(cast(Iterable[Any], graph.vertices())): # type: ignore - if v in pos: - continue - pos[v] = ( - radius/3 * cos((i / num_inner_points) * 2 * pi), - radius/3 * sin((i / num_inner_points) * 2 * pi) - ) + if not inner: + return pos - i = 0 - while i < max_iter: - for v in cast(Iterable[Any], graph.vertices()): # type: ignore - if v in outer_face: - continue - 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 + 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) - if set_pos: - graph.set_pos(pos) # type: ignore + 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 diff --git a/run.sh b/run.sh index 69467de..223cd2d 100755 --- a/run.sh +++ b/run.sh @@ -55,7 +55,11 @@ with open("$SCRIPT_DIR/.vscode/settings.json", "w") as f: f.write("\n") 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" "$VENV_PYTHON" -m pip install -r "$SCRIPT_DIR/requirements.txt" @@ -116,7 +120,7 @@ _run_sh() { _files ;; completion) - _values 'shell' 'zsh' + _values 'shell' 'zsh' 'bash' ;; esac ;; @@ -124,10 +128,46 @@ _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 ;; *) - echo "Unsupported shell: $shell. Supported: zsh" >&2 + echo "Unsupported shell: $shell. Supported: zsh bash" >&2 exit 1 ;; esac