from __future__ import annotations import math from dataclasses import dataclass from PIL import Image, ImageDraw, ImageFont BG = (255, 255, 255) INK = (14, 14, 14) COORD = (178, 178, 178) COORD_FAINT = (215, 215, 215) AXIS = (16, 16, 16) TRACE = (176, 83, 52) TEAL = (34, 107, 128) RUST = (176, 83, 52) PURPLE = (91, 92, 154) PLANE = (232, 240, 242) PLANE_EDGE = (150, 174, 181) Vec3 = tuple[float, float, float] Vec2 = tuple[float, float] def add(a: Vec3, b: Vec3) -> Vec3: return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) def sub(a: Vec3, b: Vec3) -> Vec3: return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) def mul(s: float, v: Vec3) -> Vec3: return (s * v[0], s * v[1], s * v[2]) def dot(a: Vec3, b: Vec3) -> float: return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] def cross(a: Vec3, b: Vec3) -> Vec3: return ( a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ) def length(v: Vec3) -> float: return math.sqrt(dot(v, v)) def normalize(v: Vec3) -> Vec3: size = length(v) return (v[0] / size, v[1] / size, v[2] / size) def mix(a: Vec3, b: Vec3, t: float) -> Vec3: return ( a[0] * (1.0 - t) + b[0] * t, a[1] * (1.0 - t) + b[1] * t, a[2] * (1.0 - t) + b[2] * t, ) def mix2(a: Vec2, b: Vec2, t: float) -> Vec2: return (a[0] * (1.0 - t) + b[0] * t, a[1] * (1.0 - t) + b[1] * t) def ease(t: float) -> float: t = max(0.0, min(1.0, t)) return 0.5 - 0.5 * math.cos(math.pi * t) U = normalize((1.0, 1.0, 1.0)) E1 = normalize((1.0, -1.0, 0.0)) E2 = normalize(cross(U, E1)) def camera_basis(view: Vec3, right_hint: Vec3 | None = None) -> tuple[Vec3, Vec3, Vec3]: view = normalize(view) if right_hint is None: right_hint = cross(view, (0.0, 0.0, 1.0)) if length(right_hint) < 0.001: right_hint = E1 right = normalize(sub(right_hint, mul(dot(right_hint, view), view))) up = normalize(cross(view, right)) return view, right, up def camera_from_view(view: Vec3, right_hint: Vec3 | None = None) -> tuple[Vec3, Vec3, Vec3]: view, right, up = camera_basis(view, right_hint) return view, mul(-1.0, right), mul(-1.0, up) def blended_camera(blend: float) -> tuple[Vec3, Vec3, Vec3]: start_view, start_right, _start_up = camera_from_view((3.2, -2.2, 4.6)) end_view = U end_right = E1 t = ease(blend) view = normalize(mix(start_view, end_view, t)) right_hint = mix(start_right, end_right, t) return camera_basis(view, right_hint) def rotate_about_axis(v: Vec3, degrees: float) -> Vec3: theta = math.radians(degrees) return add( add(mul(math.cos(theta), v), mul(math.sin(theta), cross(U, v))), mul(dot(U, v) * (1.0 - math.cos(theta)), U), ) def reflect_in_a_plane(v: Vec3) -> Vec3: normal = normalize(cross(U, (1.0, 0.0, 0.0))) return sub(v, mul(2.0 * dot(v, normal), normal)) def reflect_progress(v: Vec3, progress: float) -> Vec3: normal = normalize(cross(U, (1.0, 0.0, 0.0))) perpendicular = mul(dot(v, normal), normal) parallel = sub(v, perpendicular) return add(parallel, mul(math.cos(math.pi * ease(progress)), perpendicular)) def state_vector(height: float, radius: float, phase_degrees: float = 14.0, collapse: float = 0.0) -> Vec3: theta = math.radians(phase_degrees) radial = add(mul(radius * math.cos(theta), E1), mul(radius * math.sin(theta), E2)) return add(mul(height * (1.0 - collapse), U), radial) def orbit(v: Vec3) -> list[Vec3]: return [rotate_about_axis(v, angle) for angle in (0.0, 120.0, 240.0)] def closed_partial(points: list[Vec2], progress: float) -> list[Vec2]: progress = max(0.0, min(1.0, progress)) if progress <= 0.0: return [] closed = points + [points[0]] scaled = progress * (len(closed) - 1) full = min(len(closed) - 1, int(scaled)) frac = scaled - full out = [closed[0]] for idx in range(full): out.append(closed[idx + 1]) if full < len(closed) - 1: out.append(mix2(closed[full], closed[full + 1], frac)) return out def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: candidates = [ "segoeuib.ttf" if bold else "segoeui.ttf", "arialbd.ttf" if bold else "arial.ttf", "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", ] for name in candidates: try: return ImageFont.truetype(name, size) except OSError: continue return ImageFont.load_default() @dataclass class Projection: origin: Vec2 scale: float view: Vec3 right: Vec3 up: Vec3 @classmethod def from_view(cls, origin: Vec2, scale: float, view: Vec3 = (3.2, -2.2, 4.6)) -> "Projection": camera = camera_from_view(view) return cls(origin, scale, *camera) @classmethod def from_blend(cls, origin: Vec2, scale: float, camera_blend: float) -> "Projection": return cls(origin, scale, *blended_camera(camera_blend)) def project(self, point: Vec3) -> tuple[float, float, float]: return ( self.origin[0] + dot(point, self.right) * self.scale, self.origin[1] - dot(point, self.up) * self.scale, dot(point, self.view), ) class Renderer: def __init__(self, width: int, height: int, scale: int = 2) -> None: self.width = width self.height = height self.scale = scale self.image = Image.new("RGB", (width * scale, height * scale), BG) self.draw = ImageDraw.Draw(self.image) def blend(self, color: tuple[int, int, int], alpha: float) -> tuple[int, int, int]: alpha = max(0.0, min(1.0, alpha)) return ( round(BG[0] * (1.0 - alpha) + color[0] * alpha), round(BG[1] * (1.0 - alpha) + color[1] * alpha), round(BG[2] * (1.0 - alpha) + color[2] * alpha), ) def xy(self, point: Vec2) -> tuple[int, int]: return (round(point[0] * self.scale), round(point[1] * self.scale)) def line( self, points: list[Vec2], color: tuple[int, int, int], width: float, alpha: float = 1.0, joint: str | None = "curve", ) -> None: if alpha <= 0.0 or len(points) < 2: return self.draw.line( [self.xy(point) for point in points], fill=self.blend(color, alpha), width=max(1, round(width * self.scale)), joint=joint, ) def circle(self, center: Vec2, radius: float, color: tuple[int, int, int], alpha: float = 1.0) -> None: if alpha <= 0.0: return x, y = self.xy(center) r = round(radius * self.scale) self.draw.ellipse((x - r, y - r, x + r, y + r), fill=self.blend(color, alpha)) def polygon( self, points: list[Vec2], fill: tuple[int, int, int], fill_alpha: float, outline: tuple[int, int, int] | None = None, outline_alpha: float = 0.0, ) -> None: if fill_alpha > 0.0: self.draw.polygon([self.xy(point) for point in points], fill=self.blend(fill, fill_alpha)) if outline is not None and outline_alpha > 0.0: self.line(points + [points[0]], outline, 1.4, outline_alpha) def text( self, label: str, position: Vec2, size: int, color: tuple[int, int, int], alpha: float = 1.0, anchor: str = "mm", bold: bool = False, ) -> None: if alpha <= 0.0: return self.draw.text( (position[0] * self.scale, position[1] * self.scale), label, fill=self.blend(color, alpha), font=font(size * self.scale, bold), anchor=anchor, ) def arrow( self, start: Vec3, end: Vec3, projection: Projection, color: tuple[int, int, int], width: float, alpha: float = 1.0, head: float = 14.0, ) -> None: if alpha <= 0.0: return p0 = projection.project(start) p1 = projection.project(end) a = (p0[0], p0[1]) b = (p1[0], p1[1]) self.line([a, b], color, width, alpha, joint=None) dx = b[0] - a[0] dy = b[1] - a[1] size = math.hypot(dx, dy) if size < 4.0: return angle = math.atan2(dy, dx) spread = 2.62 pts = [ b, (b[0] + math.cos(angle + spread) * head, b[1] + math.sin(angle + spread) * head), (b[0] + math.cos(angle - spread) * head, b[1] + math.sin(angle - spread) * head), ] self.draw.polygon([self.xy(point) for point in pts], fill=self.blend(color, alpha)) def coordinate_axes(self, projection: Projection, alpha: float = 1.0, length_pos: float = 2.55) -> None: for endpoint in ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)): self.arrow(mul(-0.16, endpoint), mul(length_pos, endpoint), projection, COORD, 1.8, alpha, head=10.0) def component_axis_labels(self, projection: Projection, alpha: float = 1.0, right_offset: float = -18.0) -> None: labels = ( ("v₁", (1.0, 0.0, 0.0), (14.0, 10.0)), ("v₂", (0.0, 1.0, 0.0), (right_offset, -18.0)), ("v₃", (0.0, 0.0, 1.0), (0.0, -18.0)), ) for label, endpoint, offset in labels: point = projection.project(mul(2.55, endpoint)) position = (point[0] + offset[0], point[1] + offset[1]) bbox = self.draw.textbbox(self.xy(position), label, font=font(22 * self.scale), anchor="mm") margin = 12 * self.scale if bbox[0] < margin or bbox[1] < margin or bbox[2] > self.width * self.scale - margin or bbox[3] > self.height * self.scale - margin: continue self.text(label, position, 22, (100, 100, 100), alpha) def equal_components_key(self, position: Vec2, alpha: float = 1.0) -> None: x, y = position self.line([(x, y), (x + 38.0, y)], AXIS, 3.2, alpha) self.polygon([(x + 38.0, y), (x + 29.0, y - 4.5), (x + 29.0, y + 4.5)], AXIS, alpha) self.text("equal components", (x + 50.0, y), 22, INK, alpha, anchor="lm") def diagonal_axis(self, projection: Projection, alpha: float = 1.0, length_pos: float = 2.55) -> None: self.arrow(mul(-0.10, U), mul(length_pos, U), projection, AXIS, 3.2, alpha, head=15.0) origin = projection.project((0.0, 0.0, 0.0)) self.circle((origin[0], origin[1]), 4.0, AXIS, alpha) def orbit_trace( self, points_3: list[Vec3], projection: Projection, color: tuple[int, int, int], alpha: float = 1.0, progress: float = 1.0, width: float = 2.8, dots: bool = True, fill_alpha: float = 0.0, ) -> None: points_2 = [(projection.project(point)[0], projection.project(point)[1]) for point in points_3] if fill_alpha > 0.0: self.polygon(points_2, color, fill_alpha) path = closed_partial(points_2, progress) self.line(path, color, width, alpha) if dots: for idx, point in enumerate(points_2): if progress >= idx / 3.0: self.circle(point, 4.4, color, alpha) def vector(self, point: Vec3, projection: Projection, color: tuple[int, int, int] = TEAL, alpha: float = 1.0) -> None: self.arrow((0.0, 0.0, 0.0), point, projection, color, 3.6, alpha, head=16.0) tip = projection.project(point) self.circle((tip[0], tip[1]), 5.0, color, alpha) def reflection_plane(self, projection: Projection, alpha: float = 0.30) -> None: in_plane = normalize(sub((1.0, 0.0, 0.0), mul(dot((1.0, 0.0, 0.0), U), U))) low = -0.15 high = 2.25 half_width = 0.66 corners = [ add(mul(low, U), mul(-half_width, in_plane)), add(mul(high, U), mul(-half_width, in_plane)), add(mul(high, U), mul(half_width, in_plane)), add(mul(low, U), mul(half_width, in_plane)), ] points = [(projection.project(point)[0], projection.project(point)[1]) for point in corners] self.polygon(points, PLANE, alpha, PLANE_EDGE, min(1.0, alpha * 1.8)) def sum_zero_plane(self, projection: Projection, alpha: float = 0.18) -> None: radius = 1.72 corners = [ add(mul(-radius, E1), mul(-radius, E2)), add(mul(radius, E1), mul(-radius, E2)), add(mul(radius, E1), mul(radius, E2)), add(mul(-radius, E1), mul(radius, E2)), ] points = [(projection.project(point)[0], projection.project(point)[1]) for point in corners] self.polygon(points, PLANE, alpha, PLANE_EDGE, min(1.0, alpha * 1.8)) def output(self) -> Image.Image: return self.image.resize((self.width, self.height), Image.Resampling.LANCZOS).convert("RGB")