from __future__ import annotations import shutil import subprocess from dataclasses import dataclass from pathlib import Path from PIL import Image from symmetry_d3_rendering import ( AXIS, BG, INK, RUST, TEAL, PURPLE, Projection, Renderer, Vec3, add, ease, mix2, mul, orbit, rotate_about_axis, state_vector, ) ROOT = Path(__file__).resolve().parents[1] OUTPUT_DIR = ROOT / "content" / "drafts" / "animations" SCRATCH = OUTPUT_DIR / "_symmetry_d3_irrep_collapse_frames" FFMPEG = ROOT / ".tools" / "micromamba-anim-root" / "envs" / "anim" / "Library" / "bin" / "ffmpeg.exe" if not FFMPEG.is_file(): from imageio_ffmpeg import get_ffmpeg_exe FFMPEG = Path(get_ffmpeg_exe()) WIDTH = 1200 HEIGHT = 720 SCALE = 2 FPS = 24 START_ORIGIN = (410.0, 420.0) END_ORIGIN = (WIDTH / 2, HEIGHT / 2 - 8.0) START_SCALE = 136.0 END_SCALE = 190.0 PHASE = 45.0 @dataclass(frozen=True) class Sample: height: float radius: float color: tuple[int, int, int] SAMPLES = [ Sample(1.96, 1.06, TEAL), Sample(1.38, 0.78, RUST), Sample(0.86, 0.54, PURPLE), ] def sample_state(sample: Sample, collapse: float = 0.0) -> Vec3: return state_vector(sample.height, sample.radius, PHASE, collapse) def projection(camera_blend: float) -> Projection: t = ease(camera_blend) origin = mix2(START_ORIGIN, END_ORIGIN, t) scale = START_SCALE * (1.0 - t) + END_SCALE * t return Projection.from_blend(origin, scale, camera_blend) def reveal_tuple(active_index: int | None = None, active_value: float = 0.0, completed: int = 0) -> tuple[float, float, float]: values = [1.0 if index < completed else 0.0 for index in range(len(SAMPLES))] if active_index is not None: values[active_index] = active_value return (values[0], values[1], values[2]) def angle_tuple(active_index: int | None = None, active_angle: float = 0.0) -> tuple[float, float, float]: values = [0.0, 0.0, 0.0] if active_index is not None: values[active_index] = active_angle return (values[0], values[1], values[2]) def draw_orbits( renderer: Renderer, proj: Projection, reveals: tuple[float, float, float], collapse: float, fill_alpha: float, width: float, orbit_alpha: float, ) -> None: entries = [] for index, sample in enumerate(SAMPLES): reveal = max(0.0, min(1.0, reveals[index])) if reveal <= 0.0: continue points = orbit(sample_state(sample, collapse)) entries.append((sample.radius, sample, points, reveal)) for _radius, sample, points, reveal in sorted(entries, key=lambda item: item[0], reverse=True): renderer.orbit_trace( points, proj, sample.color, alpha=orbit_alpha, progress=reveal, width=width, dots=True, fill_alpha=fill_alpha * ease(max(0.0, reveal - 0.72) / 0.28), ) def draw_vectors( renderer: Renderer, proj: Projection, reveals: tuple[float, float, float], angles: tuple[float, float, float], collapse: float, alpha: float, ) -> None: if alpha <= 0.0: return vectors = [] for index, sample in enumerate(SAMPLES): reveal = max(0.0, min(1.0, reveals[index])) if reveal <= 0.0: continue point = rotate_about_axis(sample_state(sample, collapse), angles[index]) vectors.append((proj.project(point)[2], sample, point, reveal)) for _depth, sample, point, reveal in sorted(vectors, key=lambda item: item[0]): renderer.vector(point, proj, sample.color, alpha * reveal) def draw_frame( reveals: tuple[float, float, float], angles: tuple[float, float, float], vector_reveals: tuple[float, float, float], collapse: float = 0.0, camera_blend: float = 0.0, axes_alpha: float = 1.0, axis_alpha: float = 1.0, vector_alpha: float = 1.0, plane_alpha: float = 0.0, fill_alpha: float = 0.045, orbit_width: float = 2.9, orbit_alpha: float = 0.68, ) -> Image.Image: renderer = Renderer(WIDTH, HEIGHT, SCALE) proj = projection(camera_blend) if plane_alpha > 0.0: renderer.sum_zero_plane(proj, plane_alpha) if axes_alpha > 0.0: renderer.coordinate_axes(proj, axes_alpha) draw_orbits(renderer, proj, reveals, collapse, fill_alpha, orbit_width, orbit_alpha) if axis_alpha > 0.0: renderer.diagonal_axis(proj, axis_alpha) draw_vectors(renderer, proj, vector_reveals, angles, collapse, vector_alpha) renderer.component_axis_labels(proj, axes_alpha, right_offset=14.0) renderer.equal_components_key((815.0, 130.0), axis_alpha) if camera_blend > 0.0: renderer.draw.rectangle((renderer.xy((365.0, 40.0)), renderer.xy((835.0, 116.0))), fill=BG) renderer.text("components sum to zero", (WIDTH / 2, 62.0), 26, INK) renderer.text("v₁ + v₂ + v₃ = 0", (WIDTH / 2, 98.0), 22, INK, camera_blend) elif collapse > 0.0: renderer.text("Subtract the common component", (WIDTH / 2, 62.0), 26, INK, min(1.0, collapse * 8.0)) return renderer.output() def add_hold(frames: list[Image.Image], count: int, **kwargs: object) -> None: for _ in range(count): frames.append(draw_frame(**kwargs)) def build_frames() -> list[Image.Image]: frames: list[Image.Image] = [] add_hold( frames, 14, reveals=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0), vector_reveals=(1.0, 0.0, 0.0), fill_alpha=0.0, ) for sample_index in range(len(SAMPLES)): for turn in range(3): for frame_index in range(28): t = ease(frame_index / 27) angle = 120.0 * (turn + t) progress = (turn + t) / 3.0 frames.append( draw_frame( reveals=reveal_tuple(sample_index, progress, sample_index), angles=angle_tuple(sample_index, angle), vector_reveals=reveal_tuple(sample_index, 1.0, sample_index), fill_alpha=0.035, ) ) add_hold( frames, 6, reveals=reveal_tuple(sample_index, (turn + 1) / 3.0, sample_index), angles=angle_tuple(sample_index, 120.0 * (turn + 1)), vector_reveals=reveal_tuple(sample_index, 1.0, sample_index), fill_alpha=0.035, ) add_hold( frames, 10, reveals=reveal_tuple(sample_index, 1.0, sample_index), angles=angle_tuple(sample_index, 360.0), vector_reveals=reveal_tuple(sample_index, 0.45, sample_index), vector_alpha=0.8, fill_alpha=0.05, ) for frame_index in range(28): t = ease(frame_index / 27) frames.append( draw_frame( reveals=(1.0, 1.0, 1.0), angles=(0.0, 0.0, 0.0), vector_reveals=(0.45, 0.45, 0.45), vector_alpha=1.0 - t, fill_alpha=0.055, orbit_width=3.1, ) ) add_hold( frames, 24, reveals=(1.0, 1.0, 1.0), angles=(0.0, 0.0, 0.0), vector_reveals=(0.0, 0.0, 0.0), vector_alpha=0.0, fill_alpha=0.055, orbit_width=3.1, ) for frame_index in range(68): t = ease(frame_index / 67) frames.append( draw_frame( reveals=(1.0, 1.0, 1.0), angles=(0.0, 0.0, 0.0), vector_reveals=(0.0, 0.0, 0.0), vector_alpha=0.0, collapse=t, axes_alpha=1.0 - 0.28 * t, axis_alpha=1.0 - 0.18 * t, fill_alpha=0.055, orbit_width=3.1, ) ) for frame_index in range(62): t = ease(frame_index / 61) frames.append( draw_frame( reveals=(1.0, 1.0, 1.0), angles=(0.0, 0.0, 0.0), vector_reveals=(0.0, 0.0, 0.0), vector_alpha=0.0, collapse=1.0, camera_blend=t, axes_alpha=0.72 * (1.0 - t), axis_alpha=0.82 * (1.0 - t), plane_alpha=0.10 * t, fill_alpha=0.045, orbit_width=3.3, orbit_alpha=0.68 + 0.24 * t, ) ) add_hold( frames, 38, reveals=(1.0, 1.0, 1.0), angles=(0.0, 0.0, 0.0), vector_reveals=(0.0, 0.0, 0.0), vector_alpha=0.0, collapse=1.0, camera_blend=1.0, axes_alpha=0.0, axis_alpha=0.0, plane_alpha=0.10, fill_alpha=0.045, orbit_width=3.3, orbit_alpha=0.92, ) return frames def encode(frames: list[Image.Image]) -> tuple[Path, Path]: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) if SCRATCH.exists(): shutil.rmtree(SCRATCH) SCRATCH.mkdir() try: for index, image in enumerate(frames): image.save(SCRATCH / f"frame_{index:04d}.png") video = OUTPUT_DIR / "symmetry-d3-irrep-collapse.mp4" subprocess.run( [ str(FFMPEG), "-y", "-framerate", str(FPS), "-i", str(SCRATCH / "frame_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(video), ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) poster = OUTPUT_DIR / "symmetry-d3-irrep-collapse-poster.png" frames[-1].save(poster) return video, poster finally: if SCRATCH.exists(): shutil.rmtree(SCRATCH) def main() -> None: video, poster = encode(build_frames()) print(video) print(poster) if __name__ == "__main__": main()