连杆系统(一)利用 python manim 从零开始实现玻赛利埃连杆

769 阅读5分钟

这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战


前段时间了一个非常有意思的科普视频 为什么连杆可以画出曲线, 对连杆系统和数学动画产生了浓厚的兴趣。UP 主在视频中提到他最后实现了乘法器相关的代码,但是受限于渲染效率,遗憾最后无法快速得到动画。在感慨连杆系统的精巧以及 UP 主的优秀之余,我将在这一系列文章与实践中参考前人的思路,独立实现类似的功能,并尽可能地优化效率,为这个“课题”做出一些贡献。

背景

连杆系统(我现在的理解):由若干个点与它们之间的连杆组成,点主要分为四类:

  • 固定点:位置始终不变的点。
  • 驱动点:一切运动的起源,且运动轨迹通常是一个弧或者圆形。
  • 从动点:被驱动点或其他从动点通过连杆所驱动的点,驱动关系是固定的。
  • 轨迹点:属于从动点,运动轨迹会被记录下来作为结果图案,通常只有一个。

换句话说,构建一个连杆系统的目的是为了将圆弧转换成任何一种你想要的轨迹;ps:再次推荐先看一下前言中提到的视频。

manim:python 库,由 3b1b 出品的优秀的数学动画制作工具。在国内的活跃社区是 manim kindergarten.

准备:首先准备好 python 环境,然后按照官方教程安装 manim 与 ffmpeg.

注:本系列文章中所有的代码和使用方法都开源到了 github 中,如果觉得不错的话请点个 star 吧。

hello, linkage

在通过官方教程熟悉了 manim 的基本概念之后,就可以开始画连杆了。下面的一段代码会画出一个非常简单的连杆系统,只有两个点组成。

out.gif

其中蓝点是固定点;红点既是驱动点,也是轨迹点;这两个点中间有一个杆相连。

class Linkage0(Scene):
    def construct(self):
        radius = 2

        fixed = Dot(ORIGIN).set_color(COLOR_FIXED)
        driver = Dot(RIGHT * radius).set_color(COLOR_DRIVER)
        rod = Line(fixed, driver)

        def update_func(mob):
            mob.put_start_and_end_on(fixed.get_center(), driver.get_center())

        self.add(fixed, driver, rod)
        # drive
        while True:
            self.play(
                MoveAlongPath(driver, Circle(radius=radius)),
                UpdateFromFunc(rod, update_func)
            )

这段代码很好理解:

  • 首先指定半径为 2,然后画出一个固定点,画出一个驱动点,再画出它们的连杆。
  • 接着,定义连杆的变化函数:时刻保持首尾在两个杆上。
  • 最后将这些元素画在幕布上,然后让驱动点绕一个圆运动。

连杆系统开始驱动,就可以渲染出这段动画。

hello, real linkage!

刚才的两点系统其实只能算是 “hello, manim”,因为真正的连杆系统需要进行大量的计算来获得从动点的位置。接下来将是一个具有从动点的连杆系统,可以说,通过扩展这段代码(虽然还有很多地方需要优化),可以实现所有的连杆。

out.gif

图中有三个点,其中蓝点是固定点;红点是驱动点,绕一个圆周运动;而绿点既是从动点,也是轨迹点,它的位置由红蓝两点和两个定长的杆确定。

class Linkage1(Scene):
    def construct(self):
        radius = 3
        rod_length = 1.6

        fixed_o = Dot(ORIGIN).set_color(COLOR_FIXED)
        driver_a = Dot(RIGHT * radius).set_color(COLOR_DRIVER)
        target_b = Dot(RIGHT * radius / 2).set_color(COLOR_TARGET)

        rod_ab = Line(driver_a, target_b)
        rod_ob = Line(fixed_o, target_b)

        self.add(fixed_o, driver_a, target_b, rod_ab, rod_ob)
        # drive
        while True:
            self.play(
                MoveAlongPath(driver_a, Circle(radius=radius)),
                UpdateFromFunc(target_b, lambda d: d.move_arc_center_to(
                    insec_of_circle(fixed_o, rod_length, driver_a, rod_length)[0])),
                UpdateFromFunc(rod_ab, lambda l: l.put_start_and_end_on(driver_a.get_center(), target_b.get_center())),
                UpdateFromFunc(rod_ob, lambda l: l.put_start_and_end_on(fixed_o.get_center(), target_b.get_center()))
            )

这段代码也比较简单:

  • 首先确定半径和杆长,然后画出各个点和杆的初始位置。
  • 接着展开画布,指定各个对象的运动规则:
    • 驱动点仍然绕着一个圆周运动
    • 从动点按 insec_of_circle 函数的结果运动
    • 最后更新两个杆的位置

这里使用 insec_of_circle 来确定从动点的位置,所谓 insec_of_circle 其实就是两个圆的交点。当两个点 A, O 的位置确定之后,并且从动点 B 距离它们的距离 ab, ob 也确定了,那么从动点 B 的位置其实就是 以 A 为圆心,ab 为半径的圆以 O 为圆心,ob 为半径的圆 的交点。只需要解个方程即可获得从动点的位置。如果有两个交点,那么其中一个是“上凸”,另一个是“下凸”,只要始终选择同一个方向就好。

PeaucellierLinkage

波塞利埃连杆,由 18 世纪的法国军官波塞利埃发明,可以使连杆系统输出直线。

out.gif

在图中,两个蓝点还是定点;中间的红点是驱动点,两个黄点和绿点是从动点,绿点也是轨迹点。

关于绿点的轨迹为什么是直线有一个比较精妙的证明,可以看下视频或者搜索引擎查找一下~

# TODO: specify a good initial status
class PeaucellierLinkage(Scene):
    def construct(self):
        radius = 3

        fixed_o = Dot(ORIGIN).set_color(COLOR_FIXED)
        fixed_a = Dot(LEFT * radius).set_color(COLOR_FIXED)
        driver_b = Dot(RIGHT * radius).set_color(COLOR_DRIVER)
        driven_c = Dot(RIGHT * 2 - UP).set_color(COLOR_DRIVEN)
        driven_d = Dot(RIGHT * 2 + UP).set_color(COLOR_DRIVEN)
        target_e = Dot(RIGHT * radius * 2).set_color(COLOR_TARGET)

        # TODO: refine codes.
        length_ad = 7
        rod_ad = Line(fixed_a, driven_d)
        length_ac = 7
        rod_ac = Line(fixed_a, driven_c)
        length_bd = 2
        rod_bd = Line(driver_b, driven_d)
        length_bc = 2
        rod_bc = Line(driver_b, driven_c)
        length_ce = 2
        rod_ce = Line(driven_c, target_e)
        length_de = 2
        rod_de = Line(driven_d, target_e)
        rod_ob = Line(fixed_o, driver_b)

        driver_path = Arc(-TAU / 8, TAU / 4, radius=radius)
        target_path = Line(RIGHT*4.42+UP*3.05, RIGHT*4.42+DOWN*3.05)

        self.add(fixed_o, fixed_a, driver_b, driven_c, driven_d, target_e, rod_ad, rod_ac, rod_bd, rod_bc, rod_ce, rod_de, rod_ob, driver_path, target_path)
        # drive

        self.play(
            MoveAlongPath(driver_b, driver_path),
            UpdateFromFunc(rod_ob, lambda l: l.put_start_and_end_on(fixed_o.get_center(), driver_b.get_center())),
            UpdateFromFunc(driven_c, lambda c: c.move_arc_center_to(insec_of_circle(fixed_a, length_ac, driver_b, length_bc)[0])),
            UpdateFromFunc(driven_d, lambda d: d.move_arc_center_to(insec_of_circle(fixed_a, length_ad, driver_b, length_bd)[1])),
            UpdateFromFunc(rod_ac, lambda l: l.put_start_and_end_on(fixed_a.get_center(), driven_c.get_center())),
            UpdateFromFunc(rod_ad, lambda l: l.put_start_and_end_on(fixed_a.get_center(), driven_d.get_center())),
            UpdateFromFunc(rod_bc, lambda l: l.put_start_and_end_on(driver_b.get_center(), driven_c.get_center())),
            UpdateFromFunc(rod_bd, lambda l: l.put_start_and_end_on(driver_b.get_center(), driven_d.get_center())),
            UpdateFromFunc(target_e, lambda e: e.move_arc_center_to(insec_of_circle(driven_c, length_ce, driven_d, length_de)[0])),
            UpdateFromFunc(rod_ce, lambda l: l.put_start_and_end_on(driven_c.get_center(), target_e.get_center())),
            UpdateFromFunc(rod_de, lambda l: l.put_start_and_end_on(driven_d.get_center(), target_e.get_center())),
        )

代码的结构与 Linkage1 基本相同,就不再多做解释。

后记

从后面两个例子中已经可以看出来,现在的代码结构非常需要优化,所以下一篇会主要讲为了快速实现一个大型的高效连杆系统,代码中做的数据结构与抽象。