diff --git a/posts/polycount/sand-1/todo.txt b/posts/polycount/sand-1/todo.txt index b9bd8d8..a9b5086 100644 --- a/posts/polycount/sand-1/todo.txt +++ b/posts/polycount/sand-1/todo.txt @@ -3,3 +3,5 @@ mp4s saved do not get copied or linked over to the rendering folder center figures make sure to object-fit: scale + +use SympyAnimationWrapper from stereo/2 diff --git a/posts/stereo/2/.gitignore b/posts/stereo/2/.gitignore new file mode 100644 index 0000000..2641667 --- /dev/null +++ b/posts/stereo/2/.gitignore @@ -0,0 +1 @@ +*.mp4 diff --git a/posts/stereo/2/anim.py b/posts/stereo/2/anim.py new file mode 100644 index 0000000..07b77f7 --- /dev/null +++ b/posts/stereo/2/anim.py @@ -0,0 +1,67 @@ +import logging +import time +from pathlib import Path +from threading import Thread +from typing import Callable + +from matplotlib import animation, pyplot as plt + + +def animate( + fig, frames: list[int] | None, **kwargs +) -> Callable[..., animation.FuncAnimation]: + """Decorator-style wrapper for FuncAnimation.""" + # Why isn't this how the function is exposed by default? + return lambda func: animation.FuncAnimation(fig, func, frames, **kwargs) + +# writer = animation.writers["ffmpeg"](fps=15, metadata={"artist": "Me"}) + +def bindfig(fig): + """ + Create a function which is compatible with the signature of `pyplot.figure`, + but alters the already-existing figure `fig` instead. + """ + def ret(**kwargs): + for i, j in kwargs.items(): + if i == "figsize": + if j is not None: + fig.set_figwidth(j[0]) + fig.set_figheight(j[1]) + continue + fig.__dict__["set_" + i](j) + return fig + return ret + +class SympyAnimationWrapper: + """ + Context manager for binding a figurebinfoinfoinfoinfoinfoinfo + """ + def __init__(self, filename: Path | str): + self._filename = filename if isinstance(filename, Path) else Path(filename) + self._current_figure = plt.gcf() + + self._figure_temp = plt.figure + + def __enter__(self): + plt.figure = bindfig(self._current_figure) + return self.animate + + def __exit__(self, *_): + plt.figure = self._figure_temp + + def animate(self, *args, **kwargs): + def wrapper(func): + ret = animate(self._current_figure, *args, **kwargs)(func) + current_save = ret.save + def save(*args, **kwargs): + if self._filename.exists(): + logging.error("Ignoring saving animation '%s': file already exists", self._filename) + return + thread = Thread(target=lambda: time.sleep(1) or plt.close()) + thread.run() + current_save(self._filename, *args, **kwargs) + ret.save = save + + return ret + + return wrapper diff --git a/posts/stereo/2/index.qmd b/posts/stereo/2/index.qmd index a7cae19..c73201a 100644 --- a/posts/stereo/2/index.qmd +++ b/posts/stereo/2/index.qmd @@ -23,6 +23,32 @@ categories: } +```{python} +#| echo: false + +from dataclasses import dataclass + +import matplotlib.pyplot as plt +import sympy +from sympy.abc import t + +from anim import SympyAnimationWrapper + +# rational circle function +o = (1 + sympy.I*t) / (1 - sympy.I*t) + +# cache some values for plotting later +def generate_os(amt): + ret = sympy.sympify(1) + for _ in range(amt): + yield ret + ret = (ret * o).expand().cancel().simplify() + +os = list(generate_os(10)) +cs = [sympy.re(i).simplify() for i in os] +ss = [sympy.im(i).simplify() for i in os] +``` + In my previous post, I discussed the stereographic projection of a circle as it pertains to complex numbers, as well as its applications in 2D and 3D rotation. @@ -340,15 +366,46 @@ will plot a $p/1$ polar rose as t ranges over $(-\infty, \infty)$. ```{python} #| echo: false #| fig-cap: | -#| p/1 polar roses as rational curves +#| p/1 polar roses as rational curves. #| Since *t* never reaches infinity, a bite appears to be taken out of the graphs near (-1, 0). -# TODO: video +@dataclass +class Rose: + x: sympy.Expr + y: sympy.Expr + title: str | None = None -import sympy -from sympy.abc import t + @classmethod + def from_rational(cls, p: int, q: int): + return cls( + cs[p]*cs[q], + cs[p]*ss[q], + title=f"$x = c_{{{p}}}c_{{{q}}}; y = c_{{{p}}}s_{{{q}}}$", + ) -o = (1 + sympy.I*t) / (1 - sympy.I*t) + +def animate_roses(filename: str, arguments: list[Rose], interval=200): + with SympyAnimationWrapper(filename) as animate: + @animate(list(range(len(arguments))), interval=interval) + def ret(fr): + plt.clf() + argument = arguments[fr] + + sympy.plot_parametric( + argument.x, + argument.y, + xlim=(-2,2), + ylim=(-2,2), + title=argument.title, + backend="matplotlib", + ) + + ret.save() # type: ignore + +animate_roses( + "polar_roses_1.mp4", + [ Rose.from_rational(i, 1) for i in range(1, 11) ], +) ``` $q = 1$ happens to match the subscript *c* term of *x* and *s* term of *y*, so one might wonder @@ -365,7 +422,13 @@ will plot a $p/q$ polar rose as t ranges over $(-\infty, \infty)$. #| echo: false #| fig-cap: p/q polar roses as rational curves -# TODO: video +animate_roses( + "polar_roses_2.mp4", + [ + Rose.from_rational(i,j) + for i,j in [(1,1),(1,2),(2,1),(3,1),(3,2),(2,3),(1,3),(1,4),(3,4),(4,3),(4,1)] + ], +) ``` @@ -637,9 +700,38 @@ Indeed, the sequence of curves with parametrization $R_n(t) = 2nt$ approximate t ```{python} #| echo: false -#| fig-cap: $x = {c_1 \over 1 - 2c_1} \quad y = {s_1 \over 1 - 2c_1}$ +#| fig-cap: Approximations to the Archimedean spiral -# TODO: video +with SympyAnimationWrapper("approximate_archimedes.mp4") as animate: + @animate(list(range(10)), interval=500) + def ret(fr): + plt.clf() + + p = sympy.plot_parametric( + t*sympy.cos(t), + t*sympy.sin(t), + xlim=(-4,4), + ylim=(-4,4), + label="Archimedean Spiral", + backend="matplotlib", + show=False + ) + i = fr + 1 + p.extend( + sympy.plot_parametric( + 2*i*t*cs[i], + 2*i*t*ss[i], + (t, -5, 5), + line_color="black", + label=f"$R_{{{i}}}(t) = {2*i}t$", + backend="matplotlib", + show=False + ) + ) + p.show() + plt.legend() + + ret.save() # type: ignore ``` Since R necessarily defines a rational curve, the curves will never be equal, diff --git a/posts/stereo/2/stereograph_notes.py b/posts/stereo/2/stereograph_notes.py deleted file mode 100644 index 13b02b1..0000000 --- a/posts/stereo/2/stereograph_notes.py +++ /dev/null @@ -1,93 +0,0 @@ -from sympy import symbols, I, im, re, sympify, plot_parametric, sin, cos -import numpy as np -import matplotlib.pyplot as plt -from matplotlib import animation - -class LazyList: - def __init__(self, generator): - self._list = [] - self._generator = generator - - def __getitem__(self, idx): - final = idx - if isinstance(idx, slice): - final = idx.stop - while final >= len(self._list): - self._list.append(next(self._generator)) - return self._list[idx] - - def __repr__(self): - if len(self._list): - return repr(self._list)[:-1] + ", ...]" - return "[...]" - -t = symbols('t', real=True) - -e = (1 + I*t) / (1 - I*t) - -def generate_es(): - ret = sympify(1) - while True: - yield ret - ret = (ret * e).expand().cancel().simplify() - -es = LazyList(generate_es()) -cs = LazyList(map(lambda x: re(x).simplify(), es)) -ss = LazyList(map(lambda x: im(x).simplify(), es)) - - -def make_animation(fig, frames, **kwargs): - #fig.tight_layout() - #why isn't this how the function is exposed by default - return lambda func: animation.FuncAnimation(fig, func, frames, init_func=lambda: None, **kwargs) - -def bindfig(fig): - def ret(**kwargs): - for i, j in kwargs.items(): - if i == "figsize": - if j is not None: - fig.set_figwidth(j[0]) - fig.set_figheight(j[1]) - continue - fig.__dict__["set_" + i](j) - return fig - return ret - -def anim_curves(stuff=[], interval=200): - #zero[0,0] = 1 - fig = plt.gcf() - #plt.colorbar() - - #temp = plt.figure - #I hate doing it, but there's no other way to get the figure before it's plotted - plt.figure = bindfig(fig) - - @make_animation(fig, len(stuff), interval=interval) - def ret(fr): - fig.clf() - idx1, idx2 = stuff[fr] - - plot_parametric(idx1, idx2, xlim=(-2,2), ylim=(-2,2), backend='matplotlib') - #plt.title(f"$x = c_{{{idx1}}}c_{{{idx2}}}; y = c_{{{idx1}}}s_{{{idx2}}}$") - plt.tight_layout() - - return ret - -plot_roses = lambda roses: anim_curves([(cs[i]*cs[j], cs[i]*ss[j]) for i,j in roses], interval=500) -#plot_roses([(1,1),(1,2),(2,1),(3,1),(3,2),(2,3),(1,3),(1,4),(3,4),(4,3),(4,1)]).save() - -def archimedes_frames(): - writer = animation.writers['ffmpeg'](fps=15, metadata={'artist': 'Me'}) - #fig = plt.gcf() - - for i in range(10): - #for i in range(1): - i += 1 - p = plot_parametric(t*cos(t), t*sin(t), xlim=(-4,4), ylim=(-4,4), label="Archimedean Spiral", show=False) - p.save(f"frame{i}.png") - p.extend( - plot_parametric(2*i*t*cs[i], 2*i*t*ss[i], line_color="black", label=f"$R_{{{i}}}(t) = {2*i}t$", - show=False) - ) - p.legend = True - p.save(f"frame%02d.png" % i)