From 33cae47ffa8e2af6c674909ead3064837addb3ad Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 04:21:59 +0200 Subject: [PATCH] feat(animations): Manim source for knob, XY-pad, and fan-out explainers --- assets/media/src/plan.md | 43 ++++++++ assets/media/src/s1_knob_mapping.py | 113 +++++++++++++++++++ assets/media/src/s2_xy_pad.py | 134 +++++++++++++++++++++++ assets/media/src/s3_fanout.py | 162 ++++++++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 assets/media/src/plan.md create mode 100644 assets/media/src/s1_knob_mapping.py create mode 100644 assets/media/src/s2_xy_pad.py create mode 100644 assets/media/src/s3_fanout.py diff --git a/assets/media/src/plan.md b/assets/media/src/plan.md new file mode 100644 index 0000000..bb1c9fa --- /dev/null +++ b/assets/media/src/plan.md @@ -0,0 +1,43 @@ +# Manim Explainers — Plan (workstream G) + +3Blue1Brown-style educational explainers for Manifold's core ideas: how physical +controls map to numbers, how a single control fans out to many parameters, and how +the two feedback/training modes reshape a mapping. British spelling in all on-screen copy. + +## Shared visual language + +- Palette: Classic 3B1B on `#1C1C1C`. Primary `#58C4DD` (control side), Green + `#83C167` (numbers/outputs), Yellow `#FFFF00` (accent / the moving value). + Dislike/push = warm red `#FF6B6B`. Anchors (placed sounds) = green dots. +- Monospace font throughout (`MONO`), per the skill (Pango kerning). +- Opacity layering: primary 1.0, context 0.4, structure (axes/grid) 0.15. +- Breathing room: `self.wait()` after every reveal. + +## The five scripts + +| # | File | Scene(s) | Teaches | +|---|------|----------|---------| +| 1 | `s1_knob_mapping.py` | `KnobMapping` | A knob is a 1:1 readout of one number in [0,1]. Rotation ⇄ value in lockstep. | +| 2 | `s2_xy_pad.py` | `XYPad` | A 2-D pad projects to two *independent* numbers A and B, shown as sliders. | +| 3 | `s3_fanout.py` | `Fanout` | The same 2-D control drives 3→4→5 outputs (N sliders), across control types. | +| 4 | `s4_feedback.py` | `GeometricDislike`, `ExploreAndPlace`, `FeedbackContrast` | The two feedback modes on a loss/mapping surface: geometric push-away vs explore-and-place (the recommended default). | + +Each scene is independently renderable; scripts are stitched with ffmpeg per piece. + +## Narrative arcs + +1. **Knob = 1:1.** Misconception corrected: a control isn't "magic", it's a number. + Show the dial; bind a 0–1 readout; sweep both ways; they never disagree. +2. **XY → two numbers.** One gesture, two readings. Move the dot; A reads the + horizontal, B reads the vertical; they move independently. The pad is just two + knobs stacked at right angles. +3. **Fan-out.** The interesting bit: one control, many outputs. A small network + sits between the control and N sliders. Grow N (3→4→5) and cycle the control + type (knob, fader, touchpad, 2-D joystick, 3-D joystick) — the principle holds. +4. **Feedback.** The mapping is a landscape. Two ways to teach it: + - *Geometric dislike*: you have a liked region; a thumbs-down carves the current + point directionally **away** from the liked centroid (directed repulsion). + - *Explore and place* (DEFAULT): randomise the net → audition → "I like that" → + drop it in a corner → randomise again → place another → interpolate between + placed anchors. Positive-only; you never reason about "away from what". + End on a side-by-side contrast and the recommendation. diff --git a/assets/media/src/s1_knob_mapping.py b/assets/media/src/s1_knob_mapping.py new file mode 100644 index 0000000..c3324ec --- /dev/null +++ b/assets/media/src/s1_knob_mapping.py @@ -0,0 +1,113 @@ +"""Manifold explainer 1 — Knob = 1:1 mapping. + +A knob rotates; a paired 0-1 number rises and falls in lockstep. The point: a +physical control is nothing more than a readout of one number. + +Render: + manim -qh s1_knob_mapping.py KnobMapping +""" + +from manim import * + +BG = "#1C1C1C" +PRIMARY = "#58C4DD" # control side +NUMBER = "#83C167" # the number / value side +ACCENT = "#FFFF00" # the live value +DIM = 0.4 +MONO = "Monospace" + + +def knob_group(value_tracker, radius=1.3): + """A dial whose pointer angle tracks value in [0,1] over a 300 degree sweep.""" + start_ang = -150 * DEGREES # bottom-left + sweep = 300 * DEGREES + + face = Circle(radius=radius, color=PRIMARY, stroke_width=6) + face.set_fill(PRIMARY, opacity=0.06) + + # the swept arc (the track the pointer travels along) + track = Arc(radius=radius + 0.18, start_angle=start_ang, angle=sweep, + color=PRIMARY, stroke_width=4).set_opacity(DIM) + + def make_pointer(): + ang = start_ang + value_tracker.get_value() * sweep + tip = np.array([np.cos(ang), np.sin(ang), 0]) * (radius - 0.18) + line = Line(ORIGIN, tip, color=ACCENT, stroke_width=8) + return line + + pointer = always_redraw(make_pointer) + hub = Dot(ORIGIN, radius=0.08, color=ACCENT) + return VGroup(face, track), pointer, hub + + +class KnobMapping(Scene): + def construct(self): + self.camera.background_color = BG + + title = Text("A knob is just a number", font_size=44, color=PRIMARY, + weight=BOLD, font=MONO) + self.play(Write(title), run_time=1.5) + self.wait(1.0) + self.play(title.animate.scale(0.62).to_edge(UP, buff=0.5), run_time=1.0) + self.wait(0.3) + + val = ValueTracker(0.5) + + dial, pointer, hub = knob_group(val) + dial.shift(LEFT * 3.2) + pointer.shift(LEFT * 3.2) + hub.shift(LEFT * 3.2) + + knob_label = Text("control", font_size=24, color=PRIMARY, font=MONO) + knob_label.next_to(dial, DOWN, buff=0.4) + + self.play(Create(dial), FadeIn(hub), run_time=1.2) + self.add(pointer) + self.play(FadeIn(knob_label), run_time=0.8) + self.wait(0.5) + + # the number side: a vertical 0-1 bar + live readout + bar_h = 3.0 + bar = Rectangle(width=0.5, height=bar_h, color=NUMBER, stroke_width=4) + bar.shift(RIGHT * 2.6) + bar_bottom = bar.get_bottom() + + fill = always_redraw(lambda: Rectangle( + width=0.5, height=max(1e-3, val.get_value() * bar_h), + color=NUMBER, fill_color=NUMBER, fill_opacity=0.8, stroke_width=0, + ).move_to(bar_bottom + UP * (val.get_value() * bar_h) / 2)) + + tick0 = Text("0", font_size=20, color=NUMBER, font=MONO).next_to(bar, DL, buff=0.15) + tick1 = Text("1", font_size=20, color=NUMBER, font=MONO).next_to(bar, UL, buff=0.15) + + readout = always_redraw(lambda: Text( + f"{val.get_value():.2f}", font_size=40, color=ACCENT, font=MONO, + ).next_to(bar, RIGHT, buff=0.5)) + + num_label = Text("value", font_size=24, color=NUMBER, font=MONO) + num_label.next_to(bar, DOWN, buff=0.55) + + # the binding arrow + arrow = Arrow(dial.get_right() + RIGHT * 0.1, bar.get_left() + LEFT * 0.1, + color=WHITE, stroke_width=4, buff=0.2).set_opacity(DIM) + + self.play(Create(bar), FadeIn(tick0), FadeIn(tick1), run_time=1.0) + self.add(fill, readout) + self.play(GrowArrow(arrow), FadeIn(num_label), run_time=1.0) + self.wait(1.0) + + bind = Text("they move in lockstep", font_size=26, color=WHITE, font=MONO) + bind.to_edge(DOWN, buff=0.6) + self.play(FadeIn(bind), run_time=0.8) + self.wait(0.8) + + # sweep up, down, and to extremes -- pointer and number never disagree + self.play(val.animate.set_value(1.0), run_time=2.0, rate_func=smooth) + self.wait(0.8) + self.play(val.animate.set_value(0.0), run_time=2.5, rate_func=smooth) + self.wait(0.8) + self.play(val.animate.set_value(0.73), run_time=1.5, rate_func=smooth) + self.wait(1.5) + + self.play(FadeOut(Group(*self.mobjects)), run_time=0.6) + self.wait(0.3) diff --git a/assets/media/src/s2_xy_pad.py b/assets/media/src/s2_xy_pad.py new file mode 100644 index 0000000..f5d1de6 --- /dev/null +++ b/assets/media/src/s2_xy_pad.py @@ -0,0 +1,134 @@ +"""Manifold explainer 2 — XY pad projects to two numbers. + +A 2-D pad is two independent 0-1 readings. Moving the dot moves A (horizontal) +and B (vertical) independently, each shown as a slider. + +Render: + manim -qh s2_xy_pad.py XYPad +""" + +from manim import * + +BG = "#1C1C1C" +PRIMARY = "#58C4DD" # pad / control +A_COL = "#FFFF00" # A axis (horizontal) +B_COL = "#83C167" # B axis (vertical) +DIM = 0.4 +MONO = "Monospace" + + +def slider(value_tracker, length, colour, horizontal=True): + """A track + a knob that tracks value in [0,1] along `length`.""" + if horizontal: + track = Line(LEFT * length / 2, RIGHT * length / 2, color=colour, + stroke_width=5).set_opacity(DIM) + knob = always_redraw(lambda: Dot( + track.get_start() + RIGHT * value_tracker.get_value() * length, + radius=0.13, color=colour)) + fill = always_redraw(lambda: Line( + track.get_start(), + track.get_start() + RIGHT * value_tracker.get_value() * length, + color=colour, stroke_width=5)) + else: + track = Line(DOWN * length / 2, UP * length / 2, color=colour, + stroke_width=5).set_opacity(DIM) + knob = always_redraw(lambda: Dot( + track.get_start() + UP * value_tracker.get_value() * length, + radius=0.13, color=colour)) + fill = always_redraw(lambda: Line( + track.get_start(), + track.get_start() + UP * value_tracker.get_value() * length, + color=colour, stroke_width=5)) + return VGroup(track), fill, knob + + +class XYPad(Scene): + def construct(self): + self.camera.background_color = BG + + title = Text("One pad, two numbers", font_size=44, color=PRIMARY, + weight=BOLD, font=MONO) + self.play(Write(title), run_time=1.5) + self.wait(1.0) + self.play(title.animate.scale(0.62).to_edge(UP, buff=0.5), run_time=1.0) + self.wait(0.3) + + # the pad + side = 3.4 + ax = ValueTracker(0.5) + bx = ValueTracker(0.5) + + pad = Square(side_length=side, color=PRIMARY, stroke_width=5) + pad.set_fill(PRIMARY, opacity=0.05) + pad.shift(LEFT * 3.0) + bl = pad.get_corner(DL) + + grid = VGroup() + for f in (0.25, 0.5, 0.75): + grid.add(Line(bl + RIGHT * f * side, bl + RIGHT * f * side + UP * side, + color=PRIMARY, stroke_width=1).set_opacity(0.15)) + grid.add(Line(bl + UP * f * side, bl + UP * f * side + RIGHT * side, + color=PRIMARY, stroke_width=1).set_opacity(0.15)) + + dot = always_redraw(lambda: Dot( + bl + RIGHT * ax.get_value() * side + UP * bx.get_value() * side, + radius=0.13, color=WHITE)) + + # guide lines from the dot to each axis + vline = always_redraw(lambda: DashedLine( + bl + RIGHT * ax.get_value() * side, + bl + RIGHT * ax.get_value() * side + UP * bx.get_value() * side, + color=A_COL, stroke_width=2).set_opacity(DIM)) + hline = always_redraw(lambda: DashedLine( + bl + UP * bx.get_value() * side, + bl + RIGHT * ax.get_value() * side + UP * bx.get_value() * side, + color=B_COL, stroke_width=2).set_opacity(DIM)) + + self.play(Create(pad), Create(grid), run_time=1.2) + self.add(dot) + pad_label = Text("XY pad", font_size=24, color=PRIMARY, font=MONO) + pad_label.next_to(pad, DOWN, buff=0.4) + self.play(FadeIn(pad_label), run_time=0.6) + self.wait(0.5) + self.add(vline, hline) + + # A slider (horizontal) and B slider (vertical) on the right + a_track, a_fill, a_knob = slider(ax, 3.0, A_COL, horizontal=True) + a_group = VGroup(a_track, a_fill, a_knob) + a_group.shift(RIGHT * 3.0 + UP * 1.4) + a_lab = always_redraw(lambda: Text( + f"A = {ax.get_value():.2f}", font_size=28, color=A_COL, font=MONO + ).next_to(a_track, UP, buff=0.3)) + + b_track, b_fill, b_knob = slider(bx, 3.0, B_COL, horizontal=False) + b_group = VGroup(b_track, b_fill, b_knob) + b_group.shift(RIGHT * 3.6 + DOWN * 1.4) + b_lab = always_redraw(lambda: Text( + f"B = {bx.get_value():.2f}", font_size=28, color=B_COL, font=MONO + ).next_to(b_track, RIGHT, buff=0.4)) + + self.play(Create(a_track), Create(b_track), run_time=1.0) + self.add(a_fill, a_knob, a_lab, b_fill, b_knob, b_lab) + self.wait(0.5) + + note = Text("A reads across, B reads up -- independently", + font_size=24, color=WHITE, font=MONO).to_edge(DOWN, buff=0.55) + self.play(FadeIn(note), run_time=0.8) + self.wait(1.0) + + # move horizontally only: A changes, B holds + self.play(ax.animate.set_value(0.9), run_time=1.6, rate_func=smooth) + self.wait(0.6) + # move vertically only: B changes, A holds + self.play(bx.animate.set_value(0.85), run_time=1.6, rate_func=smooth) + self.wait(0.6) + # a diagonal sweep: both change together + self.play(ax.animate.set_value(0.15), bx.animate.set_value(0.2), + run_time=2.0, rate_func=smooth) + self.wait(0.6) + self.play(ax.animate.set_value(0.65), bx.animate.set_value(0.7), + run_time=1.6, rate_func=smooth) + self.wait(1.5) + + self.play(FadeOut(Group(*self.mobjects)), run_time=0.6) + self.wait(0.3) diff --git a/assets/media/src/s3_fanout.py b/assets/media/src/s3_fanout.py new file mode 100644 index 0000000..f541f23 --- /dev/null +++ b/assets/media/src/s3_fanout.py @@ -0,0 +1,162 @@ +"""Manifold explainer 3 — Dimensionality fan-out. + +The same low-dimensional control drives N outputs (3, then 4, then 5), with a +small network in between. Shown for several control types: knob, fader, touchpad, +2-D joystick, 3-D joystick. Control on one side, N sliders on the other. + +Render: + manim -qh s3_fanout.py Fanout +""" + +from manim import * + +BG = "#1C1C1C" +PRIMARY = "#58C4DD" # control +NET = "#FF6B6B" # the network in the middle +OUT = "#83C167" # outputs +ACCENT = "#FFFF00" +DIM = 0.4 +MONO = "Monospace" + + +def mini_control(kind): + """Return a small VGroup glyph + label for a control type, plus how many + numbers it emits (its dimensionality).""" + if kind == "knob": + face = Circle(radius=0.55, color=PRIMARY, stroke_width=5) + ptr = Line(ORIGIN, UP * 0.45, color=ACCENT, stroke_width=6).rotate( + -40 * DEGREES, about_point=ORIGIN) + g = VGroup(face, ptr, Dot(ORIGIN, radius=0.05, color=ACCENT)) + return g, "knob", 1 + if kind == "fader": + track = Line(DOWN * 0.6, UP * 0.6, color=PRIMARY, stroke_width=5) + cap = Rectangle(width=0.4, height=0.18, color=ACCENT, fill_color=ACCENT, + fill_opacity=1).move_to(UP * 0.15) + return VGroup(track, cap), "fader", 1 + if kind == "touchpad": + sq = Square(side_length=1.1, color=PRIMARY, stroke_width=5) + sq.set_fill(PRIMARY, opacity=0.06) + d = Dot(sq.get_center() + RIGHT * 0.25 + UP * 0.18, radius=0.09, color=WHITE) + return VGroup(sq, d), "touchpad", 2 + if kind == "joystick2d": + ring = Circle(radius=0.6, color=PRIMARY, stroke_width=5) + stick = Line(ORIGIN, RIGHT * 0.3 + UP * 0.35, color=ACCENT, stroke_width=7) + knob = Dot(RIGHT * 0.3 + UP * 0.35, radius=0.1, color=ACCENT) + return VGroup(ring, stick, knob), "2-D joystick", 2 + if kind == "joystick3d": + ring = Circle(radius=0.6, color=PRIMARY, stroke_width=5) + stick = Line(ORIGIN, RIGHT * 0.28 + UP * 0.32, color=ACCENT, stroke_width=7) + knob = Dot(RIGHT * 0.28 + UP * 0.32, radius=0.1, color=ACCENT) + # a small twist arc to suggest the 3rd (rotational) axis + twist = Arc(radius=0.78, start_angle=20 * DEGREES, angle=120 * DEGREES, + color=ACCENT, stroke_width=3).set_opacity(0.7) + twist_tip = Triangle(color=ACCENT, fill_opacity=1).scale(0.06).move_to( + twist.get_end()) + return VGroup(ring, stick, knob, twist, twist_tip), "3-D joystick", 3 + raise ValueError(kind) + + +class Fanout(Scene): + def construct(self): + self.camera.background_color = BG + + title = Text("One control, many outputs", font_size=44, color=PRIMARY, + weight=BOLD, font=MONO) + self.play(Write(title), run_time=1.5) + self.wait(1.0) + self.play(title.animate.scale(0.6).to_edge(UP, buff=0.4), run_time=1.0) + self.wait(0.3) + + # --- Part A: grow N (3 -> 4 -> 5) with a fixed 2-D control --- + ctrl, ctrl_name, ndim = mini_control("touchpad") + ctrl.scale(1.3).shift(LEFT * 4.5) + ctrl_lab = Text(f"{ctrl_name} ({ndim} numbers)", font_size=22, + color=PRIMARY, font=MONO).next_to(ctrl, DOWN, buff=0.4) + + net = RoundedRectangle(width=1.4, height=2.4, corner_radius=0.15, + color=NET, stroke_width=4) + net.set_fill(NET, opacity=0.08).shift(LEFT * 0.6) + net_lab = Text("network", font_size=20, color=NET, font=MONO).next_to( + net, DOWN, buff=0.3) + + self.play(FadeIn(ctrl), FadeIn(ctrl_lab), run_time=1.0) + self.play(Create(net), FadeIn(net_lab), run_time=1.0) + in_arrow = Arrow(ctrl.get_right(), net.get_left(), color=WHITE, + stroke_width=4, buff=0.25).set_opacity(DIM) + self.play(GrowArrow(in_arrow), run_time=0.6) + self.wait(0.5) + + def make_sliders(n): + grp = VGroup() + top = 1.9 + gap = 3.8 / max(1, n - 1) if n > 1 else 0 + for i in range(n): + y = top - i * gap if n > 1 else 0 + t = Line(ORIGIN, RIGHT * 2.0, color=OUT, stroke_width=4).set_opacity(DIM) + t.move_to(RIGHT * 3.7 + UP * y) + frac = 0.5 + 0.4 * np.sin(i * 1.3 + n) + fill = Line(t.get_start(), t.get_start() + RIGHT * 2.0 * frac, + color=OUT, stroke_width=4) + knob = Dot(t.get_start() + RIGHT * 2.0 * frac, radius=0.1, color=OUT) + lab = Text(f"out {i+1}", font_size=16, color=OUT, font=MONO).next_to( + t, LEFT, buff=0.2) + grp.add(VGroup(t, fill, knob, lab)) + return grp + + counter = Text("3 outputs", font_size=26, color=OUT, font=MONO).to_edge( + DOWN, buff=0.55) + + sliders = make_sliders(3) + fan = VGroup(*[Line(net.get_right(), s[0].get_left(), color=NET, + stroke_width=2).set_opacity(0.5) for s in sliders]) + self.play(Create(sliders), Create(fan), FadeIn(counter), run_time=1.2) + self.wait(1.2) + + for n in (4, 5): + new_sliders = make_sliders(n) + new_fan = VGroup(*[Line(net.get_right(), s[0].get_left(), color=NET, + stroke_width=2).set_opacity(0.5) for s in new_sliders]) + new_counter = Text(f"{n} outputs", font_size=26, color=OUT, + font=MONO).to_edge(DOWN, buff=0.55) + self.play( + ReplacementTransform(sliders, new_sliders), + ReplacementTransform(fan, new_fan), + ReplacementTransform(counter, new_counter), + run_time=1.3) + sliders, fan, counter = new_sliders, new_fan, new_counter + self.wait(1.0) + + self.wait(0.5) + # clear the output side + counter, keep the network, swap the control type + self.play(FadeOut(sliders), FadeOut(fan), FadeOut(counter), + FadeOut(ctrl), FadeOut(ctrl_lab), run_time=0.6) + + # --- Part B: cycle control types into the same network -> 5 outputs --- + note = Text("...and any control feeds the same fan-out", + font_size=24, color=WHITE, font=MONO).to_edge(DOWN, buff=0.55) + self.play(FadeIn(note), run_time=0.6) + + five = make_sliders(5) + five_fan = VGroup(*[Line(net.get_right(), s[0].get_left(), color=NET, + stroke_width=2).set_opacity(0.5) for s in five]) + self.play(Create(five), Create(five_fan), run_time=0.8) + + prev_ctrl = None + prev_lab = None + for kind in ("knob", "fader", "touchpad", "joystick2d", "joystick3d"): + g, name, nd = mini_control(kind) + g.scale(1.3).shift(LEFT * 4.5) + lab = Text(f"{name} ({nd} number{'s' if nd > 1 else ''})", + font_size=22, color=PRIMARY, font=MONO).next_to( + g, DOWN, buff=0.4) + if prev_ctrl is None: + self.play(FadeIn(g), FadeIn(lab), run_time=0.7) + else: + self.play(ReplacementTransform(prev_ctrl, g), + ReplacementTransform(prev_lab, lab), run_time=0.9) + prev_ctrl, prev_lab = g, lab + self.wait(0.9) + + self.wait(1.2) + self.play(FadeOut(Group(*self.mobjects)), run_time=0.6) + self.wait(0.3)