跳转至

圆还是方?

管口从前方看是圆,在镜中 是方(杉原,2016)。它是同一条三维曲线,由两个视图求解。

examples/sugihara_cylinder.py
"""圆还是方?杉原厚司的歧义柱体,由两个投影构建而成。

从上方 40° 角看去,管子的边缘是一个完美的圆;它在身后镜子中的反射——即从背面看到的同一
根管子——是一个正方形。将其旋转半圈,两者互换。其诀窍在于:从正面看,边缘上的一个点
(x, y, z) 在屏幕上的高度为 v_f = y sin α + z cos α;从背面看则为 v_b = −y sin α + z cos α。
要求正面呈圆形 (x, v_f) = (cos t, sin t),背面呈圆角正方形 (x, v_b) = (cos t, ±(1 − |cos t|⁶)^⅙),
并求解:y = (v_f − v_b)/(2 sin α),z = (v_f + v_b)/(2 cos α)。壁面是竖直的,因此从两侧看
都像柱体的侧面。从其他任何角度看,它都是一根带有蝶形足迹的波浪管(Sugihara, 2016)。
"""

import math

import numpy as np

import manimgx as m

ALPHA = 40 * m.DEGREES  # 两个视角的仰角
SQUARENESS = 6.0
WALL = 1.1  # 壁高
MIRROR_Y = 2.6  # 镜子是平面 y = MIRROR_Y
SAMPLES = 480


def rim() -> np.ndarray:
    """未旋转时的边缘:(SAMPLES + 1, 3)。"""
    t = np.linspace(0, m.TAU, SAMPLES + 1)
    front = np.sin(t)
    back = np.sign(np.sin(t)) * (1 - np.abs(np.cos(t)) ** SQUARENESS) ** (
        1 / SQUARENESS
    )
    y = (front - back) / (2 * np.sin(ALPHA))
    z = (front + back) / (2 * np.cos(ALPHA))
    return np.stack([np.cos(t), y, z], axis=1)


def turned(points: np.ndarray, turn: float) -> np.ndarray:
    """这些点绕竖直轴旋转 `turn` 角度后的结果。"""
    c, s = np.cos(turn), np.sin(turn)
    return points @ np.array([[c, s, 0], [-s, c, 0], [0, 0, 1]])


def mirrored(points: np.ndarray) -> np.ndarray:
    return points * np.array([1, -1, 1]) + np.array([0, 2 * MIRROR_Y, 0])


def wall(top: np.ndarray) -> np.ndarray:
    """从边缘向下悬挂 WALL 高度的竖直壁:先边缘的点,再其底部的点。"""
    return np.concatenate([top, top - np.array([0, 0, WALL])])


def wall_triangles(n: int) -> np.ndarray:
    """壁面的三角形,在 n 个点的边缘下方:一排四边形。"""
    i = np.arange(n - 1)
    return np.concatenate(
        [np.stack([i, i + 1, n + i + 1], 1), np.stack([i, n + i + 1, n + i], 1)]
    )


def tube(points: np.ndarray, radius: float, sides: int = 10) -> np.ndarray:
    """沿闭合折线(平行移动标架)的带光照管体:每个点周围一圈 `sides` 个顶点。"""
    tangent = np.gradient(points, axis=0)
    tangent /= np.linalg.norm(tangent, axis=1, keepdims=True)
    seed = np.cross(tangent[0], [0.3, 0.5, 0.8])
    x, y, z = (seed / np.linalg.norm(seed)).tolist()
    normals = [(x, y, z)]
    # 逐点处理,使用浮点数:numpy 的开销会使 3 维向量的计算相形见绌
    for tx, ty, tz in tangent[1:].tolist():
        d = x * tx + y * ty + z * tz
        x, y, z = x - d * tx, y - d * ty, z - d * tz
        length = math.sqrt(x * x + y * y + z * z)
        x, y, z = x / length, y / length, z / length
        normals.append((x, y, z))
    n_ = np.array(normals)
    b_ = np.cross(tangent, n_)
    a = np.linspace(0, m.TAU, sides, endpoint=False)
    ring = (
        np.cos(a)[None, :, None] * n_[:, None] + np.sin(a)[None, :, None] * b_[:, None]
    )
    return (points[:, None] + radius * ring).reshape(-1, 3)


def tube_triangles(n: int, sides: int = 10) -> np.ndarray:
    """管体的三角形,沿 n 个点的折线:各圈之间的四边形。"""
    i = np.arange(n - 1)[:, None]
    j = np.arange(sides)[None, :]
    k = (j + 1) % sides
    return np.stack(
        [
            np.stack([i * sides + j, (i + 1) * sides + j, (i + 1) * sides + k], -1),
            np.stack([i * sides + j, (i + 1) * sides + k, i * sides + k], -1),
        ],
        2,
    ).reshape(-1, 3)


class SugiharaCylinder(m.ThreeDScene):
    def construct(self) -> None:
        turn = m.ValueTracker(0.0)
        upright = rim()
        triangles = [
            wall_triangles(SAMPLES + 1),
            tube_triangles(SAMPLES + 1),
            tube_triangles(SAMPLES + 1),
        ]

        def object_parts(reflect: bool) -> list[np.ndarray]:
            top = turned(upright, turn.get_value())
            if reflect:
                top = mirrored(top)
            return [
                wall(top),
                tube(top, 0.045),
                tube(top - np.array([0, 0, WALL]), 0.02),
            ]

        def build(reflect: bool, colors: tuple[str, str, str]) -> m.Group:
            parts = m.Group(
                *(
                    m.MeshMobject(verts, tris, shade_in_3d=True, fill_color=color)
                    for verts, tris, color in zip(
                        object_parts(reflect), triangles, colors, strict=True
                    )
                )
            )
            shown = turn.get_value()

            # 只有旋转会移动各部件:旋转之间它们保持各自的点不变
            def follow(parts: m.Mobject) -> None:
                nonlocal shown
                if turn.get_value() != shown:
                    shown = turn.get_value()
                    for part, verts in zip(
                        parts.submobjects, object_parts(reflect), strict=True
                    ):
                        part.set_points(verts)

            parts.add_updater(follow)
            return parts

        tube_obj = build(False, ("#ff6b4a", "#ffe08a", "#ffd0b0"))
        reflection = build(True, ("#b8493a", "#c9ad63", "#b89a88"))
        mirror = m.Polygon(
            [-2.5, MIRROR_Y, -2.4],
            [2.5, MIRROR_Y, -2.4],
            [2.5, MIRROR_Y, 3.0],
            [-2.5, MIRROR_Y, 3.0],
            fill_color="#8fb8ff",
            fill_opacity=0.10,
            stroke_color="#dfe8ff",
            stroke_width=2,
            shade_in_3d=True,
        )

        # 魔术视角:上方 40°,近似正交投影
        self.set_camera_orientation(
            phi=90 * m.DEGREES - ALPHA,
            theta=-90 * m.DEGREES,
            zoom=0.9,
            focal_distance=120,
            frame_center=np.array([0.0, 1.6, 0.0]),
        )
        title = m.Text("Circle or square?", font_size=40).to_corner(m.UL)
        subtitle = m.Text("one tube in front of a mirror", font_size=24).set_color(
            m.GREY_B
        )
        subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.12)
        views = m.VGroup(
            m.MathTex(r"v_f = \phantom{-}y\sin\alpha + z\cos\alpha", font_size=32),
            m.MathTex(r"v_b = -y\sin\alpha + z\cos\alpha", font_size=32),
        ).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
        solve = m.VGroup(
            m.MathTex(r"y = \frac{v_f - v_b}{2\sin\alpha}", font_size=32),
            m.MathTex(r"z = \frac{v_f + v_b}{2\cos\alpha}", font_size=32),
        ).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.25)
        m.VGroup(views, solve).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.45).to_edge(
            m.RIGHT, buff=0.5
        )
        in_front = (
            m.Text("the tube", font_size=24)
            .set_color(m.GREY_B)
            .move_to([-3.4, -2.2, 0])
        )
        behind = (
            m.Text("its reflection", font_size=24)
            .set_color(m.GREY_B)
            .move_to([-3.9, 1.6, 0])
        )
        truth = m.Text("neither: a wavy tube with a bow-tie footprint", font_size=28)
        truth.to_corner(m.DR)
        self.add_fixed_in_frame_mobjects(
            title, subtitle, solve, views, truth, in_front, behind
        )
        self.remove(solve, views, truth, in_front, behind)

        self.add(reflection, mirror, tube_obj)
        self.play(
            m.FadeIn(in_front),
            m.FadeIn(behind),
            self.camera.zoom_tracker.animate.set_value(0.94),
            run_time=3,
        )
        # 3–10 秒:将管子旋转半圈:圆 ↔ 方
        self.play(turn.animate.set_value(np.pi), run_time=6, rate_func=m.smooth)
        self.wait(1)
        # 10–18 秒:离开魔术视角:真相
        self.play(m.FadeOut(in_front), m.FadeOut(behind), run_time=0.5)
        self.move_camera(
            phi=62 * m.DEGREES,
            theta=-50 * m.DEGREES,
            focal_distance=16,
            zoom=1.15,
            frame_center=np.array([0.0, 0.6, -0.4]),
            run_time=3.5,
        )
        self.play(m.FadeIn(truth), turn.animate.set_value(2 * np.pi), run_time=4)
        # 18–22 秒:从正上方看:足迹是一个透镜形
        self.move_camera(
            phi=6 * m.DEGREES,
            theta=-90 * m.DEGREES,
            zoom=2.2,
            frame_center=np.array([0.0, 0.0, 0.0]),
            run_time=3.5,
        )
        self.play(m.FadeOut(truth), run_time=0.5)
        # 22–30 秒:回到魔术视角,及其构造原理
        self.move_camera(
            phi=90 * m.DEGREES - ALPHA,
            theta=-90 * m.DEGREES,
            zoom=0.9,
            focal_distance=120,
            frame_center=np.array([0.0, 1.6, 0.0]),
            run_time=3.5,
        )
        self.play(m.Write(views), m.Write(solve), run_time=2)
        self.play(self.camera.zoom_tracker.animate.set_value(0.94), run_time=2.5)


if __name__ == "__main__":
    SugiharaCylinder().render("sugihara_cylinder.mp4")