diff --git a/src/zeta-ball/zb.py b/src/zeta-ball/zb.py new file mode 100644 index 0000000..c70bcb3 --- /dev/null +++ b/src/zeta-ball/zb.py @@ -0,0 +1,281 @@ +from manim import * +import json, numpy as np + +D = json.load(open('durations.json')) +BG = "#0b0a12"; GOLD = "#f0b44c"; PINK = "#e2559a"; TEAL = "#4fd1c5"; FG = "#e9e4f2"; MUTED = "#9a93ad" +COOL = "#6f7ad8"; WARM = "#e0a050"; GREY = "#8a8a9a" +config.background_color = BG + +ZEROS = [14.134725, 21.022040, 25.010858, 30.424876, 32.935062, 37.586178, 40.918719, 43.327073, 48.005151, 49.773832] + +class Timed: + def setup_time(self, i): + self.budget = D[i] + 0.6; self.used = 0.0 + def go(self, *anims, rt=1.0): + self.play(*anims, run_time=rt); self.used += rt + def rest(self, extra=0.0): + self.wait(max(0.1, self.budget - self.used + extra)) + +def cap(text, where=DOWN, size=0.5): + return Text(text, font="Georgia", color=MUTED).scale(size).to_edge(where, buff=0.45) + +# ---------- the ball: radius r, resting on the plane z=0 at the origin (s = 1/2); 1 unit of s = SC manim units +r = 1.25; SC = 4*r # s=0 at x=-2r, s=1 at x=+2r +C = np.array([0, 0, r]); LAMP = np.array([0, 0, 2*r]) +def s_to_plane(x, y): # s = 1/2 + x + iy -> plane point + return np.array([SC*x, SC*y, 0.0]) +def plane_to_ball(p): # inverse stereographic from the lamp + u = p[:2] / (2*r); q = u @ u + return C + r*np.array([2*u[0], 2*u[1], q - 1]) / (q + 1) +def ball_to_plane(b): + d = b - LAMP; t = -LAMP[2] / min(d[2], -1e-3) + return LAMP + t*d +def ball_pt(phi, th): # axis along x: poles at phi=0, pi + return C + r*np.array([np.cos(phi), np.sin(phi)*np.cos(th), np.sin(phi)*np.sin(th)]) +def ball_curve(f, color, width=1.6, op=1.0, n=120): + return ParametricFunction(f, t_range=[0, TAU, TAU/n], color=color, stroke_width=width, stroke_opacity=op) +def latlong_on_ball(colfun=None): + g = VGroup() + for phi in np.linspace(0.25, PI-0.25, 9): + col = COOL if phi < PI/2-1e-6 else (WARM if phi > PI/2+1e-6 else GOLD) + g.add(ball_curve(lambda t, phi=phi: ball_pt(phi, t), col, 2.6 if abs(phi-PI/2) < 1e-6 else 1.4)) + for th in np.linspace(0, PI, 8, endpoint=False): + g.add(ParametricFunction(lambda p, th=th: ball_pt(p, th), t_range=[0, PI, PI/60], color=GREY, stroke_width=1.1)) + return g +def latlong_shadow(): + g = VGroup() + for phi in np.linspace(0.25, PI-0.25, 9): + col = COOL if phi < PI/2-1e-6 else (WARM if phi > PI/2+1e-6 else GOLD) + def f(t, phi=phi): + b = ball_pt(phi, t); p = ball_to_plane(b) + return np.clip(p, -9, 9) + g.add(ball_curve(f, col, 2.6 if abs(phi-PI/2) < 1e-6 else 1.4, n=160)) + for th in np.linspace(0, PI, 8, endpoint=False): + def f(p, th=th): + b = ball_pt(p, th); q = ball_to_plane(b) + return np.clip(q, -9, 9) + g.add(ParametricFunction(f, t_range=[0.02, PI-0.02, PI/90], color=GREY, stroke_width=1.1)) + return g +def ball_mesh(): + s = Sphere(center=C, radius=r*0.992, resolution=(24, 48)) + s.set_fill("#1d1a2b", opacity=0.45); s.set_stroke("#3a3550", width=0.3, opacity=0.4) + return s +def plane_floor(): + return NumberPlane(x_range=[-7, 7, 1], y_range=[-4, 4, 1], background_line_style={"stroke_color": "#2a2638", "stroke_width": 1, "stroke_opacity": 0.6}, axis_config={"stroke_color": "#3a3550"}) +def poles_and_origins(): + p0 = Dot3D(s_to_plane(-0.5, 0), radius=0.07, color=TEAL); p1 = Dot3D(s_to_plane(0.5, 0), radius=0.07, color=TEAL) + return p0, p1 + +class T3(Timed, ThreeDScene): + def cam(self, phi=65, theta=-40, zoom=1.0): + self.set_camera_orientation(phi=phi*DEGREES, theta=theta*DEGREES, zoom=zoom) + def label(self, text, size=0.5, where=DOWN): + t = cap(text, where, size); self.add_fixed_in_frame_mobjects(t); return t + +# ---------- S0: the plane +class S0(Timed, Scene): + def construct(self): + self.setup_time(0) + line = NumberLine(x_range=[-6, 6, 1], length=12, color=GREY, include_numbers=True, font_size=24) + self.go(Create(line), rt=1.6) + plane = NumberPlane(x_range=[-7, 7, 1], y_range=[-4, 4, 1], background_line_style={"stroke_color": "#2a2638", "stroke_width": 1}, axis_config={"stroke_color": GREY}) + self.go(FadeIn(plane), line.animate.set_opacity(0.3), rt=1.8) + d = Dot(np.array([1, 1, 0]), color=GOLD, radius=0.08) + a1 = Arrow(ORIGIN, RIGHT, buff=0, color=GOLD, stroke_width=3); a2 = Arrow(RIGHT, RIGHT+UP, buff=0, color=GOLD, stroke_width=3) + lab = MathTex("1 + i", color=GOLD).scale(0.8).next_to(d, UR, buff=0.1) + self.go(GrowArrow(a1), rt=0.7); self.go(GrowArrow(a2), rt=0.7); self.go(FadeIn(d), Write(lab), rt=0.9) + self.add(cap("the complex plane")) + self.rest() + +# ---------- S1: the Riemann sphere +class S1(T3): + def construct(self): + self.setup_time(1) + self.cam(62, -45, 0.95) + floor = plane_floor(); self.add(floor) + self.go(Wait(), rt=1.2) + ball = ball_mesh(); self.go(FadeIn(ball), rt=2.0) + lamp = Dot3D(LAMP, radius=0.07, color=FG); glow = Dot3D(LAMP, radius=0.16, color=FG, fill_opacity=0.25) + self.go(FadeIn(lamp), FadeIn(glow), rt=0.8) + tr = ValueTracker(0.0) + def bpt(): + a = tr.get_value(); phi = 0.75 + 0.5*np.sin(a*0.9) + return C + r*np.array([np.sin(phi)*np.cos(a), np.sin(phi)*np.sin(a), -np.cos(phi)]) + ray = always_redraw(lambda: Line(LAMP, ball_to_plane(bpt()), color=GOLD, stroke_width=1.6, stroke_opacity=0.8)) + onb = always_redraw(lambda: Dot3D(bpt(), radius=0.05, color=GOLD)); onp = always_redraw(lambda: Dot3D(ball_to_plane(bpt()), radius=0.05, color=GOLD)) + self.go(Create(ray), FadeIn(onb), FadeIn(onp), rt=0.8) + self.go(tr.animate.set_value(2*PI), rt=5.0) + self.go(FadeOut(ray), FadeOut(onb), FadeOut(onp), rt=0.5) + inf = Text("∞", font="Georgia", color=FG).scale(0.7); self.add_fixed_orientation_mobjects(inf); inf.move_to(LAMP + 0.45*OUT) + self.go(FadeIn(inf), rt=0.8) + self.label("the Riemann sphere") + self.begin_ambient_camera_rotation(rate=0.06) + self.rest() + +# ---------- S2: the zeta grid +import mpmath +mpmath.mp.dps = 15 +def euler(s): # the real zeta; its carrier is the 2-factor 1/(1 - 2^-s) + w = mpmath.zeta(mpmath.mpc(s.real, s.imag)); return complex(w) +class S2(Timed, Scene): + def construct(self): + self.setup_time(2) + plane = NumberPlane(x_range=[-7, 7, 1], y_range=[-4, 4, 1], background_line_style={"stroke_color": "#2a2638", "stroke_width": 1, "stroke_opacity": 0.5}, axis_config={"stroke_color": "#3a3550"}) + self.add(plane) + title = Text("ζ", font="Georgia", color=GOLD).scale(1.2).to_corner(UL, buff=0.5); self.add(title) + lines = VGroup() + for sig in np.linspace(1.1, 4.0, 9): + col = interpolate_color(ManimColor(COOL), ManimColor(WARM), (sig-1.1)/2.9) + lines.add(ParametricFunction(lambda t, sig=sig: np.array([sig-2.5, t, 0]), t_range=[-4.5, 4.5, 0.05], color=col, stroke_width=1.5)) + for t0 in np.linspace(-4.5, 4.5, 13): + lines.add(ParametricFunction(lambda x, t0=t0: np.array([x-2.5, t0, 0]), t_range=[1.1, 4.0, 0.05], color=GREY, stroke_width=1.0)) + self.go(Create(lines), rt=2.0) + self.go(Wait(), rt=1.5) + out = VGroup() + SC2 = 2.2 + for sig in np.linspace(1.1, 4.0, 9): + col = interpolate_color(ManimColor(COOL), ManimColor(WARM), (sig-1.1)/2.9) + def f(t, sig=sig): + w = euler(complex(sig, t)); return np.array([SC2*(w.real-0.5), SC2*w.imag, 0]) + out.add(ParametricFunction(f, t_range=[-4.5, 4.5, 0.02], color=col, stroke_width=1.5)) + for t0 in np.linspace(-4.5, 4.5, 13): + def f(x, t0=t0): + w = euler(complex(x, t0)); return np.array([SC2*(w.real-0.5), SC2*w.imag, 0]) + out.add(ParametricFunction(f, t_range=[1.1, 4.0, 0.02], color=GREY, stroke_width=1.0)) + self.go(Transform(lines, out), rt=3.2) + d0 = Dot(np.array([-SC2*0.5, 0, 0]), color=TEAL, radius=0.07); d1 = Dot(np.array([SC2*0.5, 0, 0]), color=TEAL, radius=0.07) + l0 = Text("0", font="Georgia", color=TEAL).scale(0.5).next_to(d0, DOWN, buff=0.12); l1 = Text("1", font="Georgia", color=TEAL).scale(0.5).next_to(d1, DOWN, buff=0.12) + self.go(FadeIn(d0), FadeIn(d1), FadeIn(l0), FadeIn(l1), rt=0.8) + self.add(cap("what zeta does to a grid")) + self.rest() + +# ---------- S3: it is a globe on its side +class S3(T3): + def construct(self): + self.setup_time(3) + self.cam(0.001, -90, 1.0) # straight down first + floor = plane_floor(); self.add(floor) + sh = latlong_shadow(); self.add(sh) + p0, p1 = poles_and_origins(); self.add(p0, p1) + self.go(Wait(), rt=2.0) + self.move_camera(phi=62*DEGREES, theta=-45*DEGREES, zoom=0.95, run_time=2.4); self.used += 2.4 + ball = ball_mesh(); ll = latlong_on_ball() + self.go(FadeIn(ball), rt=1.2) + self.go(ReplacementTransform(sh.copy(), ll), sh.animate.set_stroke(opacity=0.35), rt=2.6) + bp0 = Dot3D(ball_pt(0, 0), radius=0.07, color=TEAL); bp1 = Dot3D(ball_pt(PI, 0), radius=0.07, color=TEAL) + ax = Line3D(ball_pt(PI, 0), ball_pt(0, 0), color=TEAL, thickness=0.012) + self.go(FadeIn(bp0), FadeIn(bp1), Create(ax), rt=1.0) + self.label("a globe on its side: poles over 0 and 1") + self.begin_ambient_camera_rotation(rate=0.05) + self.rest() + +# ---------- S4: the equator casts the critical line +class S4(T3): + def construct(self): + self.setup_time(4) + self.cam(60, -40, 0.95) + floor = plane_floor(); sh = latlong_shadow().set_stroke(opacity=0.3); ball = ball_mesh(); ll = latlong_on_ball().set_stroke(opacity=0.45) + p0, p1 = poles_and_origins(); ax = Line3D(ball_pt(PI, 0), ball_pt(0, 0), color=TEAL, thickness=0.012) + self.add(floor, sh, ball, ll, p0, p1, ax) + self.go(Wait(), rt=3.0) + eq = ball_curve(lambda t: ball_pt(PI/2, t), GOLD, 4.0) + self.go(Create(eq), rt=1.6) + crit = Line(np.array([0, -4.2, 0]), np.array([0, 4.2, 0]), color=GOLD, stroke_width=4) + rays = VGroup(*[Line(LAMP, ball_to_plane(ball_pt(PI/2, t)), color=GOLD, stroke_width=1, stroke_opacity=0.35) for t in np.linspace(0.3, PI-0.3, 7)]) + self.go(Create(rays), rt=1.0); self.go(Create(crit), rt=1.4); self.go(FadeOut(rays), rt=0.5) + half = Dot3D(ORIGIN, radius=0.07, color=FG) + self.go(FadeIn(half), rt=0.5) + self.label("the equator casts the critical line, Re s = ½") + self.begin_ambient_camera_rotation(rate=0.04) + self.rest() + +# ---------- S5: two origins +class S5(T3): + def construct(self): + self.setup_time(5) + self.cam(72, -30, 1.05) + floor = plane_floor(); ball = ball_mesh(); ll = latlong_on_ball().set_stroke(opacity=0.35) + eq = ball_curve(lambda t: ball_pt(PI/2, t), GOLD, 4.0); crit = Line(np.array([0, -4.2, 0]), np.array([0, 4.2, 0]), color=GOLD, stroke_width=3) + p0, p1 = poles_and_origins() + self.add(floor, ball, ll, eq, crit, p0, p1) + self.go(Wait(), rt=1.5) + bot = Dot3D(ORIGIN, radius=0.11, color="#ffffff"); top = Dot3D(LAMP, radius=0.11, color="#000000"); topring = Dot3D(LAMP, radius=0.14, color=GOLD, fill_opacity=0.0) + topring.set_stroke(GOLD, width=2) + self.go(FadeIn(bot), Flash(ORIGIN, color=FG, line_length=0.3), rt=1.4) + self.go(Wait(), rt=1.2) + self.go(FadeIn(top), FadeIn(topring), Flash(LAMP, color=GOLD, line_length=0.3), rt=1.4) + lh = Text("½", font="Georgia", color=FG).scale(0.6); li = Text("∞", font="Georgia", color=GOLD).scale(0.7) + self.add_fixed_orientation_mobjects(lh, li); lh.move_to(ORIGIN + 0.5*DOWN*1 + 0.1*OUT); li.move_to(LAMP + 0.45*OUT) + self.go(FadeIn(lh), FadeIn(li), rt=0.8) + self.label("two special points on the critical line: ½ and ∞") + self.begin_ambient_camera_rotation(rate=0.04) + self.rest() + +# ---------- S6: the symmetry is a half-turn +class S6(T3): + def construct(self): + self.setup_time(6) + self.cam(60, -40, 0.95) + floor = plane_floor(); ball = ball_mesh(); ll = latlong_on_ball().set_stroke(opacity=0.5) + eq = ball_curve(lambda t: ball_pt(PI/2, t), GOLD, 4.0) + bp0 = Dot3D(ball_pt(0, 0), radius=0.08, color=TEAL); bp1 = Dot3D(ball_pt(PI, 0), radius=0.08, color=PINK) + bot = Dot3D(ORIGIN, radius=0.09, color="#ffffff"); top = Dot3D(LAMP, radius=0.09, color="#000000"); top.set_stroke(GOLD, width=2) + axis = DashedLine(ORIGIN, LAMP + 0.6*OUT, color=GOLD, stroke_width=2) + self.add(floor, ball, ll, eq, bp0, bp1, bot, top) + f = MathTex(r"s \;\longleftrightarrow\; 1 - s", color=FG).scale(0.9).to_corner(UR, buff=0.5); self.add_fixed_in_frame_mobjects(f) + self.go(FadeIn(f), rt=1.0) + self.go(Wait(), rt=2.2) + self.go(Create(axis), rt=0.8) + grp = VGroup(ll, bp0, bp1) + self.go(Rotate(grp, PI, axis=OUT, about_point=C), rt=3.0) + self.go(Wait(), rt=0.8) + self.go(Indicate(eq, color=GOLD, scale_factor=1.0), rt=1.2) + self.label("the symmetry is a half-turn: poles swap, the equator stays") + self.rest() + +# ---------- S7: the zeros, in the plane +class S7(Timed, Scene): + def construct(self): + self.setup_time(7) + plane = NumberPlane(x_range=[-7, 7, 1], y_range=[-4, 4, 1], background_line_style={"stroke_color": "#2a2638", "stroke_width": 1, "stroke_opacity": 0.5}, axis_config={"stroke_color": "#3a3550"}) + self.add(plane) + SCL = 0.14 # 1 unit of Im s + crit = Line(np.array([0, -4, 0]), np.array([0, 4, 0]), color=GOLD, stroke_width=3) + d0 = Dot(np.array([-SC*0.5, 0, 0]), color=TEAL, radius=0.06); d1 = Dot(np.array([SC*0.5, 0, 0]), color=TEAL, radius=0.06) + half = Dot(ORIGIN, color=FG, radius=0.07) + lab = Text("Re s = ½", font="Georgia", color=GOLD).scale(0.5).next_to(crit, RIGHT, buff=0.2).shift(2.6*DOWN) + self.add(crit, d0, d1, half, lab) + self.go(Wait(), rt=2.5) + zs = VGroup() + for g in ZEROS: + if g*SCL > 3.9: break + zs.add(Dot(np.array([0, g*SCL, 0]), color=FG, radius=0.06)); zs.add(Dot(np.array([0, -g*SCL, 0]), color=FG, radius=0.06)) + self.go(LaggedStart(*[GrowFromCenter(z) for z in zs], lag_ratio=0.25), rt=4.0) + q = Text("all of them?", font="Georgia", slant=ITALIC, color=PINK).scale(0.7).to_corner(UR, buff=0.5) + self.go(Write(q), rt=1.2) + self.add(cap("the Riemann hypothesis: every zero is on this line")) + self.rest() + +# ---------- S8: the zeros on the ball, climbing to the lamp +class S8(T3): + def construct(self): + self.setup_time(8) + self.cam(68, -35, 1.0) + floor = plane_floor(); ball = ball_mesh(); ll = latlong_on_ball().set_stroke(opacity=0.3) + eq = ball_curve(lambda t: ball_pt(PI/2, t), GOLD, 4.0); crit = Line(np.array([0, -4.2, 0]), np.array([0, 4.2, 0]), color=GOLD, stroke_width=2, stroke_opacity=0.6) + bot = Dot3D(ORIGIN, radius=0.09, color="#ffffff"); top = Dot3D(LAMP, radius=0.09, color="#000000"); top.set_stroke(GOLD, width=2) + self.add(floor, ball, ll, eq, crit, bot, top) + self.go(Wait(), rt=1.0) + zs = VGroup() + for g in ZEROS: + for sg in (1, -1): + zs.add(Dot3D(plane_to_ball(s_to_plane(0, sg*g/30.0)), radius=0.055, color=FG)) + self.go(LaggedStart(*[FadeIn(z) for z in zs], lag_ratio=0.12), rt=2.6) + self.go(Wait(), rt=1.0) + self.move_camera(phi=30*DEGREES, theta=-35*DEGREES, zoom=1.0, run_time=3.0); self.used += 3.0 + self.go(Flash(LAMP, color=GOLD, line_length=0.4, num_lines=16), rt=1.0) + t1 = Text("Riemann's sphere, Riemann's hypothesis", font="Georgia", slant=ITALIC, color=FG).scale(0.6) + self.add_fixed_in_frame_mobjects(t1); t1.to_edge(DOWN, buff=0.45) + self.go(FadeIn(t1), rt=1.2) + self.begin_ambient_camera_rotation(rate=0.05) + self.rest()