diff --git a/src/wick/wick.py b/src/wick/wick.py new file mode 100644 index 0000000..c8311b2 --- /dev/null +++ b/src/wick/wick.py @@ -0,0 +1,181 @@ +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 +S = 1.7 # unit sphere radius on screen + +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, size=0.5): + return Text(text, font="Georgia", color=MUTED).scale(size).to_edge(DOWN, buff=0.45) + +# --- the quaternion picture -------------------------------------------------------------- +# p = b i + c j + d k on the unit sphere; project S^3 from -1: (q1,q2,q3)/(1+q0) +def one_sided(b, c, d, th): # q = e^{i th} p, then project from -1 + ct, st = np.cos(th), np.sin(th) + q0, q1, q2, q3 = -b*st, b*ct, c*ct - d*st, d*ct + c*st + den = 1 + q0 + if abs(den) < 1e-3: den = 1e-3 if den >= 0 else -1e-3 + v = np.array([q1, q2, q3]) / den + n = np.linalg.norm(v) + if n > 9: v = v / n * 9 + return S*v +def sandwich(b, c, d, th): # q p q^-1 with q = e^{i th/2}: rotation about i by th + ct, st = np.cos(th), np.sin(th) + return S*np.array([b, c*ct - d*st, d*ct + c*st]) +# lat-long about the j axis: latitude c = cos(phi), (b, d) = sin(phi)(cos psi, sin psi) +def jpt(phi, psi): return np.sin(phi)*np.cos(psi), np.cos(phi), np.sin(phi)*np.sin(psi) + +def grid(f, th_tracker, lats=7, lons=6): + g = VGroup() + for k in range(1, lats+1): + phi = k*PI/(lats+1) + col = COOL if phi < PI/2-1e-6 else (WARM if phi > PI/2+1e-6 else GOLD) + w = 3.0 if abs(phi-PI/2) < 1e-6 else 1.4 + g.add(always_redraw(lambda phi=phi, col=col, w=w: ParametricFunction(lambda t: f(*jpt(phi, t), th_tracker.get_value()), t_range=[0, TAU, TAU/110], color=col, stroke_width=w))) + for k in range(lons): + psi = k*PI/lons + g.add(always_redraw(lambda psi=psi: ParametricFunction(lambda p: f(*jpt(p, psi), th_tracker.get_value()), t_range=[0.02, PI-0.02, PI/60], color=GREY, stroke_width=1.0))) + return g + +class Q(Timed, ThreeDScene): + def cam(self, phi=66, theta=-38, zoom=0.95): + self.set_camera_orientation(phi=phi*DEGREES, theta=theta*DEGREES, zoom=zoom) + def label(self, text, size=0.5): + t = cap(text, size); self.add_fixed_in_frame_mobjects(t); return t + def axes(self): + a = VGroup(Line3D(-S*1.25*RIGHT, S*1.25*RIGHT, color="#3a3550", thickness=0.01), Line3D(-S*1.25*UP, S*1.25*UP, color="#3a3550", thickness=0.01), Line3D(-S*1.25*OUT, S*1.25*OUT, color="#3a3550", thickness=0.01)) + li = Text("i", font="Georgia", slant=ITALIC, color=FG).scale(0.6); lj = Text("j", font="Georgia", slant=ITALIC, color=FG).scale(0.6); lk = Text("k", font="Georgia", slant=ITALIC, color=FG).scale(0.6) + self.add_fixed_orientation_mobjects(li, lj, lk); li.move_to(S*1.4*RIGHT); lj.move_to(S*1.4*UP); lk.move_to(S*1.4*OUT) + one = Dot3D(ORIGIN, radius=0.06, color=FG); l1 = Text("1", font="Georgia", color=MUTED).scale(0.45); self.add_fixed_orientation_mobjects(l1); l1.move_to(0.3*DOWN+0.25*IN) + return VGroup(a, li, lj, lk, one, l1) + def dial(self, tr): + # fixed-in-frame dial: a circle with a needle at theta, quarter marks + c = Circle(radius=0.55, color="#3a3550", stroke_width=2).to_corner(UR, buff=0.5) + marks = VGroup(*[Line(c.get_center()+0.47*np.array([np.cos(a), np.sin(a), 0]), c.get_center()+0.55*np.array([np.cos(a), np.sin(a), 0]), color=MUTED, stroke_width=2) for a in (0, PI/2, PI, 3*PI/2)]) + needle = always_redraw(lambda: Line(c.get_center(), c.get_center()+0.5*np.array([np.cos(tr.get_value()), np.sin(tr.get_value()), 0]), color=PINK, stroke_width=3)) + lab = always_redraw(lambda: Text("θ = %d°" % round(tr.get_value()*180/PI), font="Georgia", color=MUTED).scale(0.4).next_to(c, DOWN, buff=0.1)) + self.add_fixed_in_frame_mobjects(c, marks, needle, lab) + return VGroup(c, marks, needle, lab) + +# ---------- W0: the sphere we watch +class W0(Q): + def construct(self): + self.setup_time(0); self.cam() + ax = self.axes(); self.add(ax) + tr = ValueTracker(0.0); g = grid(one_sided, tr) + self.go(Create(g), rt=3.0) + self.label("unit quaternions, projected from −1: the pure imaginaries are this sphere") + self.begin_ambient_camera_rotation(rate=0.06) + self.rest() + +# ---------- W1: locked sandwich, the sphere spins +class W1(Q): + def construct(self): + self.setup_time(1); self.cam() + ax = self.axes(); self.add(ax) + tr = ValueTracker(0.0); g = grid(sandwich, tr); self.add(g) + f = Text("p ↦ q p q⁻¹", font="Georgia", slant=ITALIC, color=FG).scale(0.7).to_corner(UL, buff=0.5); self.add_fixed_in_frame_mobjects(f) + self.dial(tr) + self.go(FadeIn(f), rt=0.8) + self.go(tr.animate.set_value(TAU), rt=7.0, rate_func=linear) + self.label("locked: the sphere spins in place and never leaves itself") + self.rest() + +# ---------- W2: one-sided, to the plane +class W2(Q): + def construct(self): + self.setup_time(2); self.cam(70, -40, 0.8) + ax = self.axes(); self.add(ax) + tr = ValueTracker(0.0); g = grid(one_sided, tr); self.add(g) + f = Text("p ↦ q p, q = e^{iθ}", font="Georgia", slant=ITALIC, color=FG).scale(0.7).to_corner(UL, buff=0.5); self.add_fixed_in_frame_mobjects(f) + self.dial(tr) + self.go(FadeIn(f), rt=0.8) + self.go(Wait(), rt=3.0) + self.go(tr.animate.set_value(PI/2), rt=7.0, rate_func=smooth) + self.label("quarter turn: the same sphere, one point sent to infinity — a plane") + self.rest() + +# ---------- W3: the four quarter turns +class W3(Q): + def construct(self): + self.setup_time(3); self.cam(70, -40, 0.8) + ax = self.axes(); self.add(ax) + tr = ValueTracker(PI/2); g = grid(one_sided, tr); self.add(g) + self.dial(tr) + c = self.label("¼: plane") + self.go(Wait(), rt=1.0) + self.go(tr.animate.set_value(PI), rt=4.0, rate_func=smooth); self.go(FadeOut(c), rt=0.1); c = self.label("½: sphere, inside out") + self.go(Wait(), rt=2.0) + self.go(tr.animate.set_value(3*PI/2), rt=3.5, rate_func=smooth); self.go(FadeOut(c), rt=0.1); c = self.label("¾: plane, facing the other way") + self.go(Wait(), rt=1.5) + self.go(tr.animate.set_value(TAU), rt=3.5, rate_func=smooth); self.go(FadeOut(c), rt=0.1); c = self.label("1: home — sphere, plane, sphere, plane, sphere") + self.rest() + +# ---------- W4: chart reading +class W4(Q): + def construct(self): + self.setup_time(4); self.cam(70, -40, 0.8) + ax = self.axes(); self.add(ax) + tr = ValueTracker(PI/2); g = grid(one_sided, tr); self.add(g) + t1 = Text("plane = a chart: the sphere minus one point", font="Georgia", color=FG).scale(0.55).to_edge(UP, buff=0.5); self.add_fixed_in_frame_mobjects(t1) + self.go(FadeIn(t1), rt=1.0) + self.go(Wait(), rt=3.0) + t2 = Text("sphere = the whole thing (projective)", font="Georgia", color=GOLD).scale(0.55).next_to(t1, DOWN, buff=0.15); self.add_fixed_in_frame_mobjects(t2) + self.go(FadeIn(t2), rt=1.0) + self.go(tr.animate.set_value(0.0), rt=5.0, rate_func=smooth) + self.label("putting the missing point back = analytic continuation") + self.begin_ambient_camera_rotation(rate=0.05) + self.rest() + +# ---------- W5: the zeta grid, face on +class W5(Q): + def construct(self): + self.setup_time(5) + self.set_camera_orientation(phi=90*DEGREES, theta=0*DEGREES, zoom=0.9) # look along the i axis: the plane phase face on (y = j, z = k) + tr = ValueTracker(0.0); g = grid(one_sided, tr); self.add(g) + self.go(Wait(), rt=1.0) + self.go(tr.animate.set_value(PI/2), rt=4.0, rate_func=smooth) + m1 = Text("−1", font="Georgia", color=TEAL).scale(0.5); p1 = Text("1", font="Georgia", color=TEAL).scale(0.5) + self.add_fixed_orientation_mobjects(m1, p1); m1.move_to(S*(-1)*OUT + 0.35*DOWN*0 + 0.4*LEFT*0 + 0.35*RIGHT*0 + 0.0); m1.move_to(np.array([0, -0.45, -S])); p1.move_to(np.array([0, -0.45, S])) + d0 = Dot3D(np.array([0, 0, -S]), radius=0.07, color=TEAL); d1 = Dot3D(np.array([0, 0, S]), radius=0.07, color=TEAL) + self.go(FadeIn(d0), FadeIn(d1), FadeIn(m1), FadeIn(p1), rt=0.8) + c = self.label("plane phase: latitude and longitude about j become circles around −1 and 1") + self.go(Wait(), rt=3.0) + # freeze a copy of the current grid, then halve and slide so the foci land on 0 and 1 (along k) + frozen = VGroup(*[m.copy().clear_updaters() for m in g]); self.remove(g); self.add(frozen) + grp = VGroup(frozen, d0, d1) + self.go(grp.animate.scale(0.5, about_point=ORIGIN), rt=1.8) + self.go(grp.animate.shift(0.5*S*OUT), rt=1.8) + self.go(FadeOut(c), FadeOut(m1), FadeOut(p1), rt=0.2) + z0 = Text("0", font="Georgia", color=TEAL).scale(0.5); o1 = Text("1", font="Georgia", color=TEAL).scale(0.5); h = Text("½ critical line", font="Georgia", color=GOLD).scale(0.45) + self.add_fixed_orientation_mobjects(z0, o1, h); z0.move_to(np.array([0, -0.45, 0])); o1.move_to(np.array([0, -0.45, S])); h.move_to(np.array([0, 2.6, 0.5*S])) + self.go(FadeIn(z0), FadeIn(o1), FadeIn(h), rt=0.8) + self.label("halve and slide by ½: zeta's grid, poles on 0 and 1, equator on the critical line") + self.rest() + +# ---------- W6: the gamma factor +class W6(Q): + def construct(self): + self.setup_time(6); self.cam(66, -38, 0.9) + ax = self.axes(); self.add(ax) + tr = ValueTracker(0.0); g = grid(one_sided, tr); self.add(g) + self.begin_ambient_camera_rotation(rate=0.05) + t1 = Text("ζ: far hemisphere = mirror of the near one × Γ-factor", font="Georgia", color=FG).scale(0.5).to_edge(UP, buff=0.5); self.add_fixed_in_frame_mobjects(t1) + self.go(FadeIn(t1), rt=1.0) + self.go(Wait(), rt=4.0) + t2 = Text("ξ = ζ dressed with the Γ-factor: ξ(s) = ξ(1 − s)", font="Georgia", color=GOLD).scale(0.5).next_to(t1, DOWN, buff=0.15); self.add_fixed_in_frame_mobjects(t2) + self.go(FadeIn(t2), rt=1.0) + self.go(Wait(), rt=3.0) + self.go(tr.animate.set_value(PI), rt=4.0, rate_func=smooth) + self.label("sphere to plane is continuation; the gamma factor is the fare") + self.rest()