Skip to main content

如何画一条等宽的曲带

我有一个设计师朋友。某天他问我,“如果有一条曲线,怎么把它变成一条有宽度而且处处等宽的形状?”

我随手把它向上平移了ww单位:

他说,你这没用。我说我这有用。他掏出尺子一量,告诉我这样的带子并不是处处等宽:只有在顶点处宽度才是ww,其他地方都小于ww

这是因为带子的宽度是按法向而不是竖直方向计算的。如果从一个点作它的法线、切线、和竖直方向的线,那么会形成一个矩形的两边和对角线,而矩形中,对角线比边要长。因此,简单把每个点都向同一个方向移动不能解决问题。

我说,看来是我大意了。不过不用慌,我们可以按点来考虑,每个点的移动方向都和它的位置有关。因此,从曲线上选一点A(m,f(m))\color{red}{\text{A}(m,f(m))},过它作曲线的切线和法线,确定它的像A’\color{blue}{\text{A'}}

然后,由于切线斜率k1=f(m)k_1=f'(m),而法线斜率是它的负倒数k2=1f(m)k_2=-\frac{1}{f'(m)}。这样就不难算出两条虚线段的长。

cosθ=±11+tan2θ=±f(m)1+f(m)2,sinθ=tanθcosθ=11+f(m)2\begin{aligned} \cos\theta&=\pm\sqrt{\frac{1}{1+\tan^2\theta}}=\pm\frac{f'(m)}{\sqrt{1+f'(m)^2}},\\\sin\theta&=\tan\theta\cos\theta=\mp\frac{1}{\sqrt{1+f'(m)^2}} \end{aligned}

因为做了开方操作,产生了 ± 号——这个 ± 号是合理的,因为我们没有确定过曲线的移动方向。比如图中的情况,就是coscos取负号,sinsin取正号;而如果朝反方向移动,两个符号就要反过来。

得到两条线段长后,就不难得到A’\color{blue}{\text{A'}}的坐标了。

(m,f(m))(m±wf(m)1+f(m)2,f(m)w11+f(m)2)\begin{aligned} \color{red}{(m,f(m))}\mapsto\color{blue}{\left(m\pm w\frac{f'(m)}{\sqrt{1+f'(m)^2}},f(m)\mp w\frac{1}{\sqrt{1+f'(m)^2}}\right)} \end{aligned}

这个坐标看起来很像回事。只要在曲线上多采一点坐标,就可以用 Python 画一条曲线出来。为了检验公式的正确性,我选择了y=x2y=x^2,将这条曲线向外平移 1 个单位。

import numpy as np
import matplotlib.pyplot as plt
w = 1
x = np.linspace(-5,5,1001)
y = x ** 2
d = 2 * x
nx = x + w * d / np.sqrt(1 + d ** 2)
ny = y - w / np.sqrt(1 + d ** 2)

plt.figure(figsize=(6, 6))
plt.xlim([-15, 15])
plt.ylim([-4, 26])
plt.plot(x, y)
plt.plot(nx, ny)
plt.show()

结果看起来很令人信服。如果认真分析这条曲线,就知道它已经不是二次函数了。在靠近顶点的地方,它的形状会无限接近一个圆弧。因此,这样的变换并不是相似的,但它具有保圆性和保线性(可以证明)。

我问朋友,这样满意了吗?他拿过程序,把ww改成了-1,想让它朝反方向移动。结果输出了这样的图形:

他说,没有人能接受下面的那个“扭结”。我说,这其实是因为数学模型还不完整。稍微分析一下每个点的移动过程,就知道有些点“越界了”。

他问,那这怎么解决呢?我告诉他,这些点其实在平移后,像并不应该存在——这个变换过程不是信息对称的,顶点附近的点的信息丢失了。更具体地说,如果一个点在顶点左边,而它的像在顶点右边,那这个点就被从另一边移过来的点“盖住了”,反之亦然。

因此,我给程序打了个补丁,增加了一层过滤器过滤掉这些消失的点。

import numpy as np
import matplotlib.pyplot as plt

w = -1
x = np.linspace(-5,5,1001)
y = x ** 2
d = 2 * x
nx = x + w * d / np.sqrt(1 + d ** 2)
ny = y - w / np.sqrt(1 + d ** 2)
kept = ((x < 0) & (nx < 0)) | ((x > 0) & (nx > 0))

plt.figure(figsize=(6, 6))
plt.xlim([-15, 15])
plt.ylim([-4, 26])
plt.plot(x, y)
plt.plot(nx[kept], ny[kept])
plt.show()

这样输出的结果,满意了吗?我问他。他说,可以。

下一步,就是画出f(x)=sinxf(x)=\sin x。由于我们知道曲线向一个方向移动后,和原曲线并不是相似的,因此为了使图形整体有对称性,我们可以把它向两个方向都移动 w/2w/2 个单位。于是我改了一下参数,调整了过滤器的表达式,就得到了这样的曲线。

事实上,许多矢量绘图软件得到的效果就是这样的,比如 TeX 中的 TikZ,如果把线粗设成 2cm,画出的就是这样的曲线:

但他仔细看了看,觉得拐点太尖了,显得不自然。只要控制好宽度,应该不会得到那样的尖角。问题是,多大的移动距离会产生尖角?就拿y=x2y=x^2来说,当ww不大时,它向内缩也不会产生尖角。比如w=0.2w=0.2

那么临界情况是什么?事实上,尖角正是来自于“消失的点”:如果两条从左右分别移动而来的曲线交汇了,那么就会出现尖角,因此还是要从点消失的条件出发。在抛物线的例子里,如果一个点坐标为(m,m2)(m,m^2),而它的像的横坐标 > 0,那么它消失了。代入变换公式:

mw2m1+4m2>0w21+4m2>1w214>m2\begin{aligned} m-w\frac{2m}{\sqrt{1+4m^2}}&>0\\ w\frac{2}{\sqrt{1+4m^2}}&>1\\ w^2-\frac{1}{4}&>m^2 \end{aligned}

使它无解的条件是w<0.5w<0.5。因此只要移动距离小于0.50.5,就不会有消失的点。一般地说,如果f(k)=0f'(k)=0,那么找一点(m,f(m)),m<k(m,f(m)),m < k,假设它在变换后消失了,代入变换公式:

mwf(m)1+f(m)2>ki. f(x)>0:mkf(m)>w1+f(m)2ii. f(x)<0:mkf(m)<w1+f(m)2\begin{aligned} m-w\frac{f'(m)}{\sqrt{1+f'(m)^2}}&>k\\ \text{i. } f'(x)>0: \frac{m-k}{f'(m)}&>\frac{w}{\sqrt{1+f'(m)^2}}\\ \text{ii. } f'(x)<0: \frac{m-k}{f'(m)}&<\frac{w}{\sqrt{1+f'(m)^2}}\\ \end{aligned}

注意到,情况 i 的大于号左边为负,右边为正,因此肯定无解。这对应的是“凸曲线向外移”的情况。而我们要做的就是找到情况 ii 中使得mm无解的ww取值范围。如果把 ii 的不等式整理成一个函数g(m)g(m)平移ww个单位的表达式:

1+f(m)2f(m)(mk)w<0\begin{aligned} \frac{\sqrt{1+f'(m)^2}}{f'(m)}(m-k)-w<0 \end{aligned}

就不难理解,要求的是ww的范围,使得在m=km=k附近,g(m)wg(m)-w恒为正。那么把g(m)g(m)求个导,找它的最小值点。

g(m)=f(m)2+1f(m)f(m)(mk)f(m)2f(m)2+1=0f(m)3+f(m)f(m)(mk)=0\begin{aligned} g'(m)=\frac{\sqrt{f'(m)^2+1}}{f'(m)}-\frac{f''(m)(m-k)}{f'(m)^2\sqrt{f'(m)^2+1}}&=0\\ f'(m)^3+f'(m)-f''(m)(m-k)&=0 \end{aligned}

经过一堆不太严谨的操作,我们发现,它的解正是m=km=k:也就是,随着ww的增加,第一个消失的点不是别的点,正是顶点——这在多试验几种形状之后,也可以观察到。那么,只要求出在m=km=k处的g(m)g(m)的值,就知道ww的最大值。但此时,mkf(m)\frac{m-k}{f'(m)}是一个 0/0 型的极限,因此掏出洛必达法则。

limmkmkf(m)1+f(m)2=1f(k)1+f(k)2=1f(k)\begin{aligned} \lim_{m\to k}\frac{m-k}{f'(m)}\sqrt{1+f'(m)^2}=\frac{1}{f''(k)}\sqrt{1+f'(k)^2}=\frac{1}{f''(k)} \end{aligned}

这便是ww的最大值。

如果你对曲率了解的话,你会知道,我们推导出的正是函数在x=kx=k这一点的密切圆半径:
r=(1+f(k)2)3/2f(k)\begin{aligned} r=\frac{\left(1+f'(k)^2\right)^{3/2}}{|f''(k)|} \end{aligned}

而由于f(k)=0f'(k)=0,分子为 1,化简便得正文中的公式。 :::

如果代入k=0k=0,就可以算出 0.5,和前文推导一致。对于y=sinxy=\sin x,由于ff有周期性质,不妨设k=π2k=\frac{\pi}{2}

wmax=1sin(π/2)=1\begin{aligned} w_{\max}=\frac{1}{-\sin(\pi/2)}=-1 \end{aligned}

——这里算出了一个负数,因为实际上在x=π2x=\frac{\pi}{2}的左侧,f(x)>0f'(x)>0,此时的移动方向和上面推导中假设的方向相反。无论如何,我们知道只要 sin 曲线的移动距离小于 1,就不会有尖角出现。之前给出的图中,平移距离为 1,刚好是临界情况。验算一下,当平移距离为 0.8 时:

此时曲线是平滑的。当然,这时已经觉得曲线“开始变尖”了。

他点点头,表示理解了。突然,他想起什么似的,在纸上画了个圆:“你这里讲的方法只能解决函数y=f(x)y=f(x)的等距移动,那么如果曲线不能用函数表示,就不能找到它的导数,又该怎么处理?”

我说,只要把导数换成隐函数的导数即可。

(m,n)(m±wf(m,n)1+f(m,n)2,nw11+f(m,n)2)\begin{aligned} \color{red}{(m,n)}\mapsto\color{blue}{\left(m\pm w\frac{f'(m,n)}{\sqrt{1+f'(m,n)^2}},n\mp w\frac{1}{\sqrt{1+f'(m,n)^2}}\right)} \end{aligned}

圆太无聊了,我们代一个心形曲线进去(参数形式便于编程):

{x=16sin3ty=13cost5cos2t2cos3tcos4tdydx=4sin4t+6sin3t+10sin2t13sint48sin2tcost\begin{aligned} &\begin{cases}x=16\sin^3 t\\y=13\cos t-5\cos 2t-2\cos 3t-\cos 4t\end{cases}\\ &\frac{\mathrm{d}y}{\mathrm{d}x}=\frac{4\sin 4t+6\sin 3t+10\sin 2t-13\sin t}{48\sin^2 t\cos t} \end{aligned}

就能得到变换后的坐标。

import numpy as np
import matplotlib.pyplot as plt

t = np.linspace(0, 2 * np.pi, 1000)
x = 16 * (np.sin(t)**3)
y = 13 * np.cos(t) - 5 * np.cos(2*t) - 2 * np.cos(3*t) - np.cos(4*t)
d = (4 * np.sin(4*t) + 6 * np.sin(3*t) + 10 * np.sin(2*t) - 13 * np.sin(t)) / (48 * np.sin(t)**2 * np.cos(t))
w = 1
nx1 = x + w * d / np.sqrt(1 + d ** 2)
ny1 = y - w / np.sqrt(1 + d ** 2)
nx2 = x - w * d / np.sqrt(1 + d ** 2)
ny2 = y + w / np.sqrt(1 + d ** 2)
kept = ((x < 0) & (nx2 < 0)) | ((x > 0) & (nx2 > 0))

plt.figure(figsize=(6, 6))
plt.xlim([-20, 20])
plt.ylim([-20, 20])
plt.plot(x, y)
plt.plot(nx1[kept], ny1[kept])
plt.plot(nx2[kept], ny2[kept])
plt.show()

他说,这张图虽然总的感觉对了,但 (1) 两条曲线有上下交错;(2) 黄曲线在顶点处断了,没有形成和蓝曲线对应的尖角。

问题 (1) 是因为,当w=1w=1时,曲线上下两部分都“向下”移动,但上半部分“向内”移动,而下半部分“向外”移动,因此拼出了一条不连续的黄曲线;w=1w=-1时,得到的绿曲线同理。解决方法,是我们把两组(nx,ny)(nx,ny)t=π2t=\frac{\pi}{2}t=3π2t=\frac{3\pi}{2}的拐点处切分重组,变成一条外侧曲线和一条内侧曲线。

import numpy as np
import matplotlib.pyplot as plt

t = np.linspace(0, 2 * np.pi, 1000)
x = 16 * (np.sin(t)**3)
y = 13 * np.cos(t) - 5 * np.cos(2*t) - 2 * np.cos(3*t) - np.cos(4*t)
d = (4 * np.sin(4*t) + 6 * np.sin(3*t) + 10 * np.sin(2*t) - 13 * np.sin(t)) / (48 * np.sin(t)**2 * np.cos(t))
w = 1
nx1 = x + w * d / np.sqrt(1 + d ** 2)
ny1 = y - w / np.sqrt(1 + d ** 2)
nx2 = x - w * d / np.sqrt(1 + d ** 2)
ny2 = y + w / np.sqrt(1 + d ** 2)
kept = ((x < 0) & (nx2 < 0)) | ((x > 0) & (nx2 > 0))
nx1_recombined = np.concatenate((nx1[t < np.pi / 2], nx2[kept & (np.pi / 2 < t) & (t < 3*np.pi / 2)], nx1[t > 3*np.pi / 2]))
ny1_recombined = np.concatenate((ny1[t < np.pi / 2], ny2[kept & (np.pi / 2 < t) & (t < 3*np.pi / 2)], ny1[t > 3*np.pi / 2]))
nx2_recombined = np.concatenate((nx2[kept & (t < np.pi / 2)], nx1[(np.pi / 2 < t) & (t < 3*np.pi / 2)], nx2[kept & (t > 3*np.pi / 2)]))
ny2_recombined = np.concatenate((ny2[kept & (t < np.pi / 2)], ny1[(np.pi / 2 < t) & (t < 3*np.pi / 2)], ny2[kept & (t > 3*np.pi / 2)]))

plt.figure(figsize=(6, 6))
plt.xlim([-20, 20])
plt.ylim([-20, 20])
plt.plot(x, y)
plt.plot(nx1_recombined, ny1_recombined)
plt.plot(nx2_recombined, ny2_recombined)
plt.show()

而问题 (2) 更加棘手一点,因为数学上,原曲线在这个突变点左右两边都是垂直向下的,因此向外平移后,它们就应该在顶点的位置无限向下垂直延伸而不该相交。

从逆变换的角度想,这个尖角对应了一个图形向内移动后丧失信息的情况,所以逆变换就无法恢复这部分信息。

有多根曲线和蓝曲线对应

我用 TikZ 给他画了个曲线,说明现有的程序也处理不了这种情况:

他表示理解。不过他想了一会儿,说,那我们不妨用圆弧补上这段空白——从一对多的对应中挑选一条符合要求的曲线,总比留下一块空洞好。于是,我在拼接曲线时,又多拼了一个半圆进去。

对于这个结果,他挺满意。于是,我们非常愉快地完成了这次合作。