谐波驱动¶
应变波齿轮在一个 扁平环内实现 30:1 减速。一个 60 齿的柔性环在 62 齿环内, 每转退两齿。
examples/harmonic_drive.py
"""谐波波齿轮:一扁平环内 30:1 减速。
一个椭圆塞(波发生器)在一个 60 齿薄环(柔轮)内旋转并
将其弯成与 62 齿刚环(刚轮)在长轴两端啮合。薄环不可拉伸,
因此凸出一个模数处其齿恰好隔一个齿隙并直进直出齿隙。塞每转一圈
带动啮合经过全部 62 个齿隙但仅 60 个齿,因此柔轮每转退两齿:
β = −(N_c − N_f)/N_f · ψ = −ψ/30(C. W. Musser,1957)。刚轮的齿在此
被切为柔轮自身运动齿的包络,因此两者永不碰撞。
"""
from collections.abc import Callable
from itertools import pairwise
import numpy as np
import manimgx as m
def camera_axes(phi: float, theta: float, gamma: float) -> np.ndarray:
"""摄像机在其轨道角处的坐标轴,以行向量表示:右、上、朝向观察者。"""
def spin(a: float) -> np.ndarray: # 绕 z 轴的旋转
return np.array(
[[np.cos(a), -np.sin(a), 0.0], [np.sin(a), np.cos(a), 0.0], [0.0, 0.0, 1.0]]
)
c, s = np.cos(phi), np.sin(phi)
tilt = np.array(
[[1.0, 0.0, 0.0], [0.0, c, s], [0.0, -s, c]]
) # 绕 x 轴旋转 −φ
return spin(gamma) @ tilt @ spin(-theta - np.pi / 2)
NF, NC = 60, 62 # 柔轮和刚轮齿数
MOD = 0.1 # 模数:柔轮的节圆半径为 NF · MOD / 2
RP, W0 = NF * MOD / 2, (NC - NF) * MOD / 2 # 节圆半径;长轴处的凸起
PITCH = np.pi * MOD # 沿节线的齿距
DELTA = m.TAU / NC # 刚轮的节角
HA, HF, RIM = 0.8 * MOD, 0.9 * MOD, 1.3 * MOD # 齿顶和齿根;齿根下方的轮缘
THICK = 0.5 * PITCH # 节线上的齿厚
FLANK = np.radians(25) # 齿侧角
BALL, BALLS = 0.1, 36 # 塞与柔轮之间的轴承滚珠
CAM = -HF - RIM - 2 * BALL # 塞的轮缘,在节线下方
CLEAR, R_OUT, HUB = 0.006, RP + 5.5 * MOD, 0.55 # 齿隙;外缘;轴
STEEL, BRASS, RED = "#5d6879", "#d8b15e", "#e63946"
PLUG, PLUG_LIGHT = "#1d5a56", "#4fc1b5"
# 塞速(转/秒)在关键时刻的值,其间缓动
SPEED = [(0, 0), (8.6, 0), (10.2, 0.5), (19.2, 0.5), (21.2, 0.04), (25.2, 0.04)]
SPEED += [(27.0, 0.3), (31.0, 0.3)]
CLOSE_UP = 23.2 # 特写中红齿在长轴处相遇
Face = tuple[np.ndarray, np.ndarray, list[np.ndarray]] # 顶点、三角形、环
def pitch_line(
theta: np.ndarray, w: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""柔轮的节线,沿塞的长轴(角 0)凸出 w,在材料
角 θ 处:不可伸长的环,径向位移 w cos 2θ 和切向位移 −(w/2) sin 2θ。
返回点、单位切向量和外法向量(各为 (n, 2))。"""
rho = RP + w * np.cos(2 * theta)
phi = theta - w / (2 * RP) * np.sin(2 * theta)
d_rho, d_phi = -2 * w * np.sin(2 * theta), 1 - w / RP * np.cos(2 * theta)
radial = np.stack([np.cos(phi), np.sin(phi)], -1)
across = np.stack([-np.sin(phi), np.cos(phi)], -1)
tangent = d_rho[:, None] * radial + (rho * d_phi)[:, None] * across
tangent /= np.linalg.norm(tangent, axis=1, keepdims=True)
normal = np.stack([tangent[:, 1], -tangent[:, 0]], -1)
return rho[:, None] * radial, tangent, normal
def turn(points: np.ndarray, angle: float) -> np.ndarray:
"""点 (n, 2) 逆时针旋转 angle。"""
c, s = np.cos(angle), np.sin(angle)
return points @ np.array([[c, s], [-s, c]])
def polar(r: np.ndarray, angle: np.ndarray) -> np.ndarray:
return np.stack([r * np.cos(angle), r * np.sin(angle)], -1)
def tooth_cell() -> np.ndarray:
"""一个柔轮节距格(x 沿节线,y 垂直于节线),从 x = −p/2 到
x = p/2 的 14 个点:齿根、带直齿侧和圆角齿顶的齿、齿根。"""
half, slope = THICK / 2, np.tan(FLANK)
fillet = 0.3 * (half - HA * slope)
cy = HA - fillet # 圆角与齿顶线和齿侧相切
cx = (half * np.cos(FLANK) - cy * np.sin(FLANK) - fillet) / np.cos(FLANK)
arc = np.linspace(np.pi - FLANK, np.pi / 2, 5)
corner = np.stack([-cx + fillet * np.cos(arc), cy + fillet * np.sin(arc)], -1)
left = np.vstack([[-PITCH / 2, -HF], [-half - HF * slope, -HF], corner])
return np.vstack([left, (left * [-1, 1])[::-1]])
def place_teeth(cell: np.ndarray, psi: float, w: float) -> np.ndarray:
"""`cell` 在每个齿处的副本 (NF, k, 2),每齿刚性固定于其节线位置,
对应塞转过 ψ:柔轮自身转过 β = −ψ (NC − NF) / NF。"""
theta = m.TAU * np.arange(NF) / NF - psi * NC / NF # 从轴线起的材料角
spot, tangent, normal = pitch_line(theta, w)
pts = spot[:, None] + cell[:, :1] * tangent[:, None] + cell[:, 1:] * normal[:, None]
return turn(pts.reshape(-1, 2), psi).reshape(NF, len(cell), 2)
def spline_tooth(cell: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""刚轮的齿,切为柔轮齿的包络:在每个半径处,
它们扫过一个齿隙的区域半角,从节距中减去。运动每
隔塞转 1/62 圈重复一次(转过一个齿隙),使该段覆盖所有
齿隙。返回齿的半径(齿顶 → 齿根)和半角。"""
deep = np.array([[PITCH / 2, -HF - RIM], [-PITCH / 2, -HF - RIM]])
outline = np.vstack([cell, deep, cell[:1]]) # 轮廓下的一切都计入
radii = np.linspace(RP - W0 - HF, RP + W0 + HA, 600)
swept = np.zeros(len(radii))
for psi in np.linspace(0, DELTA, 128, endpoint=False):
pts = place_teeth(outline, psi, W0)
middle = place_teeth(np.zeros((1, 2)), psi, W0)[:, 0]
gap = np.round(np.arctan2(middle[:, 1], middle[:, 0]) / DELTA) * DELTA
r = np.linalg.norm(pts, axis=-1)
ang = np.arctan2(pts[..., 1], pts[..., 0]) - gap[:, None]
ang = np.abs((ang + np.pi) % m.TAU - np.pi)[..., None]
r = r[..., None]
# 每条轮廓边与每个半径的交点,及其角度
f = (radii - r[:, :-1]) / np.where(
r[:, 1:] == r[:, :-1], 1e-12, r[:, 1:] - r[:, :-1]
)
crossing = ang[:, :-1] + f * (ang[:, 1:] - ang[:, :-1])
crossing = np.where((f >= 0) & (f <= 1), crossing, 0)
swept = np.maximum(swept, crossing.max(axis=(0, 1)))
half = DELTA / 2 - swept - CLEAR / radii
solid = np.flatnonzero(half > 0)
keep = np.linspace(solid[0], len(radii) - 1, 14).round().astype(int)
return radii[keep], half[keep]
def prism(
face: np.ndarray, tris: np.ndarray, loops: list[np.ndarray], z0: float, z1: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""扁平零件:其顶面(顶点、三角形)在高度 z1,壁沿
每个闭合边界环下至 z0。面与壁不共享顶点,使其棱保持锐利。
返回每个顶点所复制的面顶点及其高度,以及三角形。"""
source, height, faces = [np.arange(len(face))], [np.full(len(face), z1)], [tris]
count = len(face)
for loop in loops:
n = len(loop)
source.append(np.concatenate([loop, loop]))
height.append(np.repeat([z0, z1], n))
a = np.arange(n)
b = (a + 1) % n
wall = np.vstack([np.stack([a, b, n + b], 1), np.stack([a, n + b, n + a], 1)])
faces.append(count + wall)
count += 2 * n
return np.concatenate(source), np.concatenate(height), np.vstack(faces)
def fan(center: int, rim: np.ndarray) -> np.ndarray:
return np.stack([np.full(len(rim) - 1, center), rim[:-1], rim[1:]], 1)
def strip(inner: np.ndarray, outer: np.ndarray) -> np.ndarray:
"""等长两行闭合顶点索引之间的三角形。"""
a, b, c, d = inner, np.roll(inner, -1), np.roll(outer, -1), outer
return np.vstack([np.stack([a, b, c], 1), np.stack([a, c, d], 1)])
def facing_up(points: np.ndarray, tris: np.ndarray) -> np.ndarray:
"""三角形,每个从上方看为逆时针绕向(使着色的平均
法向量一致)。"""
a, b, c = points[tris[:, 0]], points[tris[:, 1]], points[tris[:, 2]]
area = (b - a)[:, 0] * (c - a)[:, 1] - (b - a)[:, 1] * (c - a)[:, 0]
return np.where((area < 0)[:, None], tris[:, ::-1], tris)
def flexspline_face(cell: np.ndarray) -> Face:
"""柔轮顶面的材料表示,每顶点(齿、x、y)在齿自身
标架中:每齿其轮廓(13 个点)、基底中点和轮缘内边上 4 个点。
返回它们、三角形和边界环(带齿的和内部的)。
"""
contour = cell[:-1] # 下一齿的第一个点闭合此齿的齿根
inner = np.column_stack(
[[contour[0, 0], contour[1, 0], 0.0, contour[12, 0]], [-HF - RIM] * 4]
)
local = np.vstack([contour, [[0.0, -HF]], inner]) # 13 + 1 + 4 = 18 每齿
first = len(local) * np.arange(NF)[:, None]
teeth = np.vstack([fan(j + 13, j + np.arange(1, 13)) for j in first[:, 0]])
rows = [
(first + np.array(k)[None]).ravel() for k in ([14, 15, 16, 17], [0, 1, 13, 12])
]
material = np.column_stack(
[np.repeat(np.arange(NF), len(local)), np.tile(local, (NF, 1))]
)
loops = [(first + np.arange(13)[None]).ravel(), rows[0]]
return material, np.vstack([teeth, strip(*rows)]), loops
def spline_face(radii: np.ndarray, half: np.ndarray) -> Face:
"""刚轮顶面:62 个齿向内指(齿 k 中心在 (k + ½)·Δ),在
延伸至 R_OUT 的轮缘上。返回顶点、三角形和边界环(带齿的、外缘的)。"""
root = radii[-1] + CLEAR # 齿根圆,恰在柔轮触及范围之外
# 齿的边:齿根 → 齿顶 → 齿根
edge_r = np.concatenate([[root], radii[::-1], radii[1:], [root]])
edge_a = np.concatenate([[-half[-1]], -half[::-1], half[1:], [half[-1]]])
n = len(edge_r)
pts = []
for k in range(NC):
c = (k + 0.5) * DELTA
pts.append(polar(edge_r, c + edge_a))
pts.append(polar(np.full(2, root), c + np.array([0, DELTA / 2]))) # 基底,齿隙
outside = c + np.array([-half[-1], 0, half[-1], DELTA / 2])
pts.append(polar(np.full(4, R_OUT), outside))
first = (n + 6) * np.arange(NC)[:, None]
teeth = np.vstack([fan(j + n, j + np.arange(n)) for j in first[:, 0]])
inner = (first + np.array([0, n, n - 1, n + 1])[None]).ravel()
outer = (first + np.arange(n + 2, n + 6)[None]).ravel()
points = np.vstack(pts)
toothed = (first + np.append(np.arange(n), n + 1)[None]).ravel()
return (
points,
facing_up(points, np.vstack([teeth, strip(inner, outer)])),
[toothed, outer],
)
def plug_face(samples: int = 240) -> Face:
"""波发生器的椭圆凸轮(在其自身标架中,长轴沿 x)绕一个轮毂。"""
spot, _, normal = pitch_line(np.linspace(0, m.TAU, samples, endpoint=False), W0)
rim = spot + CAM * normal
hub = HUB * rim / np.linalg.norm(rim, axis=1, keepdims=True)
points = np.vstack([hub, rim])
rows = np.arange(samples), samples + np.arange(samples)
return points, facing_up(points, strip(*rows)), [rows[1], rows[0]]
def flat(outline: np.ndarray, center: tuple[float, float]) -> Face:
"""扁平多边形,从能看到其整个轮廓的一点扇出。"""
points = np.vstack([outline, [center]])
tris = fan(len(outline), np.append(np.arange(len(outline)), 0))
return points, facing_up(points, tris), [np.arange(len(outline))]
def disk(radius: float) -> Face:
return flat(
polar(np.full(48, radius), np.linspace(0, m.TAU, 48, endpoint=False)), (0, 0)
)
def arrow(start: float, stop: float, w: float) -> Face:
"""沿 +x 方向的扁平箭头。"""
neck = stop - 3 * w
half = [[start, -w / 2], [neck, -w / 2], [neck, -1.5 * w], [stop, 0]]
return flat(np.array(half + [[x, -y] for x, y in half[-2::-1]]), (neck, 0))
def ball() -> tuple[np.ndarray, np.ndarray]:
"""轴承滚珠:一个小球的顶点和三角形(6 × 10 网格)。"""
u, v = np.meshgrid(
np.linspace(0, np.pi, 7), np.linspace(0, m.TAU, 11), indexing="ij"
)
grid = np.stack([np.sin(u) * np.cos(v), np.sin(u) * np.sin(v), np.cos(u)], -1)
a = (np.arange(6)[:, None] * 11 + np.arange(10)[None]).ravel()
tris = np.vstack(
[np.stack([a, a + 11, a + 12], 1), np.stack([a, a + 12, a + 1], 1)]
)
return BALL * grid.reshape(-1, 3), tris
def speed_profile() -> tuple[np.ndarray, np.ndarray]:
"""时刻,及塞到那时转过的角度(圈数):缓动 SPEED 剖面的积分。"""
fine = np.linspace(0, SPEED[-1][0], 60001)
speed = np.zeros_like(fine)
for (t0, s0), (t1, s1) in pairwise(SPEED):
x = np.clip((fine - t0) / (t1 - t0), 0, 1)
inside = (fine >= t0) & (fine <= t1)
speed[inside] = s0 + (s1 - s0) * (x * x * (3 - 2 * x))[inside]
steps = (speed[1:] + speed[:-1]) / 2 * np.diff(fine)
return fine, np.concatenate([[0], np.cumsum(steps)])
PROFILE = speed_profile()
def plug_turns(times: np.ndarray) -> np.ndarray:
return np.interp(times, *PROFILE)
def project(camera: m.Camera, point: np.ndarray) -> np.ndarray:
"""3D 点在屏幕上的落点,以帧坐标表示(用于 HUD 标签)。"""
turned = camera_axes(camera.get_phi(), camera.get_theta(), camera.get_gamma())
p = turned @ (point - camera.frame_center)
depth = 1 - p[2] / camera.get_focal_distance()
return np.array([*(camera.get_zoom() * p[:2] / depth), 0.0])
def rgba(color: str) -> np.ndarray:
return np.array([*m.ManimColor(color).to_rgb(), 1.0])
def live(
value: Callable[[], float], places: int, shown: m.ValueTracker, right: np.ndarray
) -> m.DecimalNumber:
"""每帧跟随 `value` 的数字,右边缘在 `right` 处,由跟踪器淡入淡出
(每帧重建,故不可由动画淡入淡出)。"""
number = m.DecimalNumber(value(), num_decimal_places=places, font_size=30)
def refresh(mob: m.DecimalNumber) -> None:
mob.set_value(value()).set_opacity(shown.get_value())
mob.move_to(right, aligned_edge=m.RIGHT)
number.add_updater(refresh)
refresh(number)
return number
class HarmonicDrive(m.ThreeDScene):
def construct(self) -> None:
self.camera.background_color = "#0f1318"
cell = tooth_cell()
radii, half = spline_tooth(cell)
# 红齿是特写中长轴处中间的那个齿;整个
# 传动被转动以使其从 12 点钟方向开始
close_up = plug_turns(np.array([CLOSE_UP]))[0] * m.TAU
red = round(close_up * NC / m.TAU) % NF
start = place_teeth(np.zeros((1, 2)), 0.0, W0)[red, 0]
zero = np.pi / 2 - np.arctan2(start[1], start[0])
clock = [0.0]
bulge = m.ValueTracker(0.0) # 柔轮的凸起,以 W0 为单位
lift = {"plug": m.ValueTracker(3.8), "flex": m.ValueTracker(1.9)}
seen = {"psi": 0.0, "beta": 0.0} # 塞和环的角度,本帧绘制值
def psi() -> float:
return float(plug_turns(np.array([clock[0]]))[0] * m.TAU)
def place(xy: np.ndarray, z: np.ndarray, angle: float = 0.0) -> np.ndarray:
return np.column_stack([turn(xy, angle + zero), z])
# 刚轮:固定
face, tris, loops = spline_face(radii, half)
source, height, faces = prism(face, tris, loops, 0.0, 0.5)
spline = m.MeshMobject(
place(face[source], height), faces, shade_in_3d=True, fill_color=STEEL
)
# 柔轮:60 个刚性齿沿弯曲节线骑行,按塞将每个推出(亮)或拉入(暗)着色:应变波
material, tris, loops = flexspline_face(cell)
f_source, f_height, f_faces = prism(material[:, 1:], tris, loops, 0.02, 0.56)
tooth_of = material[f_source, 0].astype(int)
local = material[f_source, 1:]
dim, gold, bright = rgba("#5e4418"), rgba(BRASS), rgba("#fff6d0")
rest = m.TAU * np.arange(NF) / NF
def flex_update(mob: m.MeshMobject) -> None:
a, w = psi(), bulge.get_value()
theta = rest - a * NC / NF
spot, tangent, normal = pitch_line(theta, W0 * w)
xy = spot[tooth_of] + local[:, :1] * tangent[tooth_of]
xy += local[:, 1:] * normal[tooth_of]
mob.points = place(xy, f_height + lift["flex"].get_value(), a)
# 环的转动,从所绘齿测得:它们相对静止的平均角
moved = turn(spot, a)
angles = np.arctan2(moved[:, 1], moved[:, 0]) - rest
seen["psi"], seen["beta"] = a, float(np.angle(np.exp(1j * angles).mean()))
strain = w * np.cos(2 * theta)[tooth_of, None]
rows = np.where(
strain > 0,
gold + strain * (bright - gold),
gold - strain * (dim - gold),
)
rows[tooth_of == red] = rgba(RED)
mob.paint = mob.paint.but(fill=rows)
flex = m.MeshMobject(np.zeros((len(f_source), 3)), f_faces, shade_in_3d=True)
flex_update(flex)
flex.add_updater(flex_update)
# 波发生器:凸轮、其轮毂和沿长轴的箭头,以及滚珠
parts = [plug_face(), disk(HUB), arrow(HUB + 0.12, RP + W0 + CAM - 0.12, 0.07)]
levels = [(0.08, 0.46), (0.0, 0.74), (0.46, 0.47)]
tints = [PLUG, "#c9ced6", "#f1faee"]
plug_parts = []
for (face, tris, loops), (z0, z1), tint in zip(parts, levels, tints):
source, height, faces = prism(face, tris, loops, z0, z1)
mesh = m.MeshMobject(
place(face[source], height), faces, shade_in_3d=True, fill_color=tint
)
def pose(
mob: m.Mobject, xy: np.ndarray = face[source], z: np.ndarray = height
) -> None:
mob.points = place(xy, z + lift["plug"].get_value(), psi())
mesh.add_updater(pose)
plug_parts.append(mesh)
sphere, one = ball()
tris = np.vstack([one + k * len(sphere) for k in range(BALLS)])
balls = m.MeshMobject(
np.zeros((BALLS * len(sphere), 3)),
tris,
shade_in_3d=True,
fill_color="#e8ecf2",
)
def roll(mob: m.Mobject) -> None:
a = psi()
cage = (
m.TAU * np.arange(BALLS) / BALLS - 0.52 * a
) # 保持架:约半速
spot, _, normal = pitch_line(cage, W0)
z = np.full(BALLS, 0.27 + lift["plug"].get_value())
centers = place(spot + (CAM + BALL) * normal, z, a)
mob.points = (centers[:, None] + sphere[None]).reshape(-1, 3)
balls.add_updater(roll)
roll(balls)
# 在固定环上,塞每整转后环到达处长出一个刻度:相隔两齿(12°)
grid = np.linspace(0, SPEED[-1][0], 3101)
turns = np.arange(int(plug_turns(grid)[-1]) + 1)
when = np.interp(turns, plug_turns(grid), grid)
when[0] = SPEED[1][0] # 红齿起始处
root = radii[-1] + CLEAR
across = np.array([-0.028, 0.028, 0.028, -0.028])
quad = np.array([[0, 1, 2], [0, 2, 3]])
tick_tris = np.vstack([quad + 4 * k for k in range(len(turns))])
def tick_points() -> np.ndarray:
grow = np.clip((clock[0] - when) / 0.5, 0, 1)
out = []
for k, g in zip(turns, grow):
r = root + 0.07 + np.array([0, 0, 0.3, 0.3]) * max(g, 1e-3)
out.append(
turn(np.column_stack([r, across]), np.pi / 2 - k * m.TAU / 30)
)
xy = np.vstack(out)
return np.column_stack([xy, np.full(len(xy), 0.504)])
ticks = m.MeshMobject(
tick_points(), tick_tris, shade_in_3d=True, fill_color=m.WHITE
)
ticks.add_updater(lambda mob: setattr(mob, "points", tick_points()))
def advance(mob: m.Mobject, dt: float) -> None:
clock[0] += dt
driver = m.Mobject().add_updater(advance)
# 平视显示器
title = m.Text("Strain-wave gearing", font_size=38).to_corner(m.UL)
notes = [
"an elliptical plug flexes a 60-tooth ring inside a 62-tooth ring",
"the ring meshes only where the plug pushes it out",
(
"where the ring bulges, its teeth sink into the gaps; a quarter turn"
" on, they clear"
),
]
subtitles = [m.Text(t, font_size=22).set_color(m.GREY_B) for t in notes]
for sub in subtitles:
sub.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)
labels = m.VGroup()
for name, note, color in [
("wave generator", "input: an elliptical plug", PLUG_LIGHT),
("flexspline", "60 teeth: output", BRASS),
("circular spline", "62 teeth: fixed", "#aab4c3"),
]:
labels.add(
m.VGroup(
m.Text(name, font_size=26).set_color(color),
m.Text(note, font_size=20).set_color(m.GREY_B),
).arrange(m.DOWN, aligned_edge=m.RIGHT, buff=0.1)
)
reaches = [RP + W0 + CAM + 2 * BALL, RP + HA, R_OUT]
around = polar(np.ones(72), np.linspace(0, m.TAU, 72, endpoint=False))
def follow(group: m.Mobject) -> None:
"""每个标签在其部件左侧,与下方标签距离不小于 0.95。"""
floor = -np.inf
parts = zip(group[::-1], ["spline", "flex", "plug"], reaches[::-1])
for label, part, reach in parts:
z = 0.3 + (lift[part].get_value() if part in lift else 0.0)
rim = [
project(self.camera, np.array([*(reach * p), z])) for p in around
]
spot = min(rim, key=lambda q: q[0])
y = max(spot[1], floor + 0.95)
label.move_to([spot[0] - 0.3 - label.width / 2, y, 0])
floor = y
labels.add_updater(follow)
formula = m.MathTex(
r"\beta = -\frac{N_c - N_f}{N_f}\,\psi = -\frac{\psi}{30}", font_size=34
)
shown = m.ValueTracker(0.0)
rows_y = [0.3 - 0.62 * k for k in range(3)]
readings: list[tuple[Callable[[], float], int]] = [
(lambda: seen["psi"] / m.TAU, 2), # 塞转数
(lambda: seen["beta"] / m.TAU * NF, 1), # 环齿数
(lambda: seen["psi"] / seen["beta"] if seen["psi"] > 0.5 else -30.0, 1),
]
numbers = [
live(value, places, shown, np.array([-2.35, y, 0]))
for (value, places), y in zip(readings, rows_y)
]
names = [
m.MathTex(r"\text{wave generator}\;\psi", font_size=30),
m.MathTex(r"\text{flexspline}\;\beta", font_size=30),
m.MathTex(r"\psi / \beta", font_size=30),
]
units = [m.Text(u, font_size=24) for u in ("turns", "teeth", ": 1")]
for name, unit, y in zip(names, units, rows_y):
name.move_to([-6.75, y, 0], aligned_edge=m.LEFT)
unit.move_to([-2.2, y - 0.02, 0], aligned_edge=m.LEFT)
formula.move_to([-6.75, 1.55, 0], aligned_edge=m.LEFT)
statics = m.VGroup(formula, *names, *units)
closing = m.VGroup(
m.Text("62 − 60 = 2 teeth:", font_size=28),
m.Text("one turn in, two teeth back", font_size=28),
m.Text("30 : 1 in one flat stage, without backlash", font_size=22),
).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.14)
closing[2].set_color(m.GREY_B)
closing.move_to([-6.75, -2.75, 0], aligned_edge=m.LEFT)
self.add_fixed_in_frame_mobjects(
title, *subtitles, labels, statics, *numbers, closing
)
self.remove(*subtitles, labels, statics, closing)
def main_view(run_time: float) -> None:
"""从 32° 俯视传动,在帧右侧(HUD 的左侧)。"""
self.move_camera(
phi=32 * m.DEGREES,
theta=-90 * m.DEGREES,
zoom=0.92,
frame_center=[-3.35, 0.2, 0],
run_time=run_time,
)
# 0–6.5 秒:零件分开;然后组装:塞进入时弯出环
self.add(driver, spline, flex, *plug_parts, balls, ticks)
self.set_camera_orientation(
phi=63 * m.DEGREES,
theta=-78 * m.DEGREES,
zoom=0.86,
frame_center=[-0.6, 0, 2.25],
)
self.begin_ambient_camera_rotation(rate=0.05)
self.play(m.FadeIn(subtitles[0]), m.FadeIn(labels), run_time=1.2)
self.wait(1.6)
self.play(
lift["plug"].animate.set_value(1.9),
bulge.animate.set_value(1.0),
m.FadeOut(labels, run_time=0.8),
run_time=1.9,
)
self.play(
lift["plug"].animate.set_value(0.0),
lift["flex"].animate.set_value(0.0),
run_time=1.7,
)
self.stop_ambient_camera_rotation()
# 6.5–19 秒:塞旋转;环反向缓慢爬行,每转两齿
self.play(m.FadeOut(subtitles[0]), run_time=0.4)
main_view(2.0)
self.play(
m.FadeIn(subtitles[1]),
m.FadeIn(statics),
shown.animate.set_value(1.0),
run_time=1.0,
)
self.wait(SPEED[3][0] - self.time)
# 19–27 秒:特写长轴经过红齿处的啮合
axis = close_up + zero
spot = np.array([3.2 * np.cos(axis), 3.2 * np.sin(axis), 0.5])
view = (
axis + np.pi / 2
) % m.TAU - 1.5 * np.pi # 轴 − π,最接近 −π/2 的转角
self.play(
m.FadeOut(statics),
shown.animate.set_value(0.0),
m.FadeOut(subtitles[1]),
run_time=0.5,
)
self.move_camera(
phi=16 * m.DEGREES, theta=view, zoom=2.3, frame_center=spot, run_time=1.8
)
self.play(m.FadeIn(subtitles[2]), run_time=0.6)
self.wait(SPEED[5][0] - self.time - 0.5)
self.play(m.FadeOut(subtitles[2]), run_time=0.5)
main_view(1.8)
# 27–30.5 秒:结语画面
self.play(
m.FadeIn(statics),
shown.animate.set_value(1.0),
m.FadeIn(subtitles[1]),
m.FadeIn(closing, shift=0.15 * m.UP),
run_time=1.0,
)
self.wait(30.5 - self.time)
if __name__ == "__main__":
HarmonicDrive().render("harmonic_drive.mp4")