几何代码里,直线不要用斜率表示
写几何代码时,直线最自然的表示是斜率:
y = kx + b
k = (y2 − y1) / (x2 − x1)
中学课本里它很好用,因为大部分题目不会给你一条垂直线。代码不一样:用户拖两个点,随时可能拖成一条垂直线;点集是随机生成的,近垂直的情况迟早会出现。斜率在这两种情况下都不好用。
斜率的三个硬伤
1. 垂直线没有斜率
两个点 (0, 0) 和 (0, 10) 确定一条垂直线。代入公式,分母是 0,斜率不存在。
你当然可以加一个特判:if (x1 === x2) 就按垂直线处理。但特判会扩散:求交点要特判,判断点在直线哪一侧要特判,裁剪多边形要特判,每条用到这条直线的地方都要记得它可能是垂直的。特判越多,漏掉一个就是一个 bug。
2. 近垂直线会把误差放大
比垂直线更麻烦的是「几乎垂直」。两个点 (0, 0) 和 (1e-9, 10),斜率是 1e10。这个数本身没有错,但它参与后续运算时会把微小的坐标误差放大同样的倍数。
比如求两条近垂直直线的交点,本质上是解一个接近奇异的方程组。用斜率写,分母可能是两个巨大数相减,有效数字大量损失;用叉积写,分母是叉积,虽然也可能很小,但至少你能直接看到它小,并且可以用一个和尺度相关的容差去判断「是否平行」。
3. 斜率丢掉了方向
直线是有方向的:从 p 到 q 和从 q 到 p,斜率一样,但「左侧」和「右侧」反了。
裁剪、半平面判断、多边形方向,全都依赖方向。半平面可以理解成「直线的一侧」:直线把平面分成两半,保留哪一半由方向决定。用斜率表示,你得另外存一个方向标志;用两个点表示,方向天然保留在点的顺序里。
用两个点表示直线
最基本的表示:直线由两个不同的点 p、q 确定,点 x 在直线上的条件是叉积为零:
function cross(o, a, b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
const side = cross(p, q, x);
// side > 0:x 在有向直线 p→q 的左侧
// side = 0:x 在直线上
// side < 0:x 在右侧
没有除法,没有斜率,垂直线和水平线一视同仁。side 的绝对值是三角形 p,q,x 面积的两倍,量纲是长度平方;判断「在直线上」时,容差要按坐标尺度的平方来取,不能直接和 0 比。
这个表示还有一个好处:它天然带方向。交换 p 和 q,side 的符号整体翻转,左侧变右侧。裁剪时想保留哪一侧,只要控制点的顺序,不需要额外的布尔变量。
例子一:两条线段是否相交
用斜率判断线段相交,要先求两条直线的交点,再判断交点是否落在线段内,还要处理平行、垂直、重合。用叉积,只需要四次符号判断:
function segmentsProperlyIntersect(p1, p2, p3, p4) {
const d1 = cross(p3, p4, p1), d2 = cross(p3, p4, p2);
const d3 = cross(p1, p2, p3), d4 = cross(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
}
d1 和 d2 是线段 p1p2 的两个端点相对于直线 p3p4 的侧别;符号相反,说明两个端点在直线两侧。d3、d4 同理。两组都相反,两条线段才「真正穿过」彼此。
这段代码没有除法,没有斜率,也没有垂直线的特判。它用的是严格不等式,所以端点接触、共线重叠不算「proper intersection」;如果业务需要把端点接触也算相交,要另外加边界判断。严格不等式在两条线段几乎相切时也可能把「擦边」判成相交,需要容差时得单独处理。这不是遗漏,是定义的选择:先明确「相交」指什么,再写判据。
例子二:点是否在凸多边形内
同样的思路可以判断点是否在凸多边形内。凸多边形的每条边定义一个半平面,点在内部,当且仅当它位于所有边的同一侧。
function pointInConvexPolygon(p, poly, eps) {
const e = eps === undefined ? 1e-9 : eps; // 兜底值;调用方应按点集尺度传入
if (poly.length < 3) return false;
const sign = polygonIsCCW(poly) ? 1 : -1;
for (let i = 0, n = poly.length; i < n; i++) {
const a = poly[i], b = poly[(i + 1) % n];
if (sign * cross(a, b, p) < -e) return false;
}
return true;
}
polygonIsCCW 用有符号面积判断多边形方向,sign 把两种情况统一成「内部为非负」。每条边只做一次叉积,没有除法;e 是边界容差,允许点落在边上。
如果多边形是顺时针,sign 取 -1,所有叉积符号翻过来;如果多边形方向不确定,这一步不能省。用斜率写,每条边都要先算斜率和截距,还要处理垂直边,需要覆盖的分支更多。
例子三:垂直平分线作为半平面
Voronoi 图里最常用的直线是两站点的垂直平分线。用斜率写,要先算中点、再算斜率的负倒数,遇到水平/垂直还要特判;用向量写,只需要两个量:
function clipByBisector(poly, si, sj) {
const nx = sj.x - si.x, ny = sj.y - si.y;
const mx = (si.x + sj.x) / 2, my = (si.y + sj.y) / 2;
const f = p => (p.x - mx) * nx + (p.y - my) * ny;
const outPoly = [];
for (let i = 0, n = poly.length; i < n; i++) {
const a = poly[i], b = poly[(i + 1) % n];
const fa = f(a), fb = f(b);
if (fa <= 0) outPoly.push(a);
if ((fa < 0 && fb > 0) || (fa > 0 && fb < 0)) {
const t = fa / (fa - fb);
outPoly.push({ x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) });
}
}
return outPoly;
}
n = sj − si 是从 si 指向 sj 的向量,它恰好是垂直平分线的法向量;m 是中点。点 p 代入 f,得到的是 (p − m) 与法向量的点积(等于投影长度乘以 |n|)。这里不需要把 n 归一化:符号和零点位置与向量长度无关,归一化反而多一次除法。
这个 f 还有一个更直观的解释。展开一下:
f(p) = (p − m) · (sj − si)
= (|p − si|² − |p − sj|²) / 2
所以 f(p) < 0 等价于 |p − si|² < |p − sj|²,也就是「离 si 更近」。不需要开方,不需要比较两个距离,更不需要斜率。
只要 si ≠ sj,这段代码对两者的相对位置没有任何要求:水平、垂直、斜着,公式都一样。垂直线在这里不是特殊情况,只是 n 的某个分量为 0 而已。演示页在入口做了去重,si = sj 的情况不会走到这里;如果调用方不能保证这一点,要先单独处理,否则 n = 0、f 恒为 0,整块多边形都会被保留。
Voronoi 单元怎么拼、怎么裁回画布,是另一个话题;这里只取这条平分线,看它怎么被表示、怎么求交。
求交点:只保留一次除法
上面的 clipByBisector 里出现了一次除法:
const t = fa / (fa - fb);
这是整段代码里唯一一处「除以变量」的除法(中点的 /2 是常数除法,浮点下是精确的),而且只在 fa、fb 严格异号时执行。分母 fa − fb 不会因为符号相消变成 0:两个端点严格异号时,它是两个绝对值相加,|fa − fb| = |fa| + |fb|。
不过,如果边两端都贴近平分线,两个绝对值本身都很小,分母仍然可以很小,交点精度会下降——这通常意味着这条边几乎与平分线相切,需要靠容差或退化处理。比起「先算斜率、再解方程组」,这个除法的条件仍然好得多。
t 落在 (0, 1) 内,可以直接用来做插值。求完交点就结束,不需要把直线本身存成斜率形式。
矩形裁剪:除法可以存在,但要能说清分母
同一个演示页里还有一段按矩形裁剪多边形的代码,内部有一个按 x 切割的闭包,取出来是这样:
function cutX(a, b, x) {
const t = (x - a.x) / (b.x - a.x);
return { x, y: a.y + t * (b.y - a.y) };
}
这里直接除了 b.x − a.x,看起来违反了「不要除以可能为零的量」。但调用它的前提是:这条边真的穿过了 x = 常数 这条切割线。如果 b.x === a.x,这条边平行于切割线,两个端点会在同一侧,inside 判断要么都真要么都假,cutX 根本不会被调用;如果这条边与切割线重合,两个端点都在边界上,inside 也都为真,同样不会调用。
这就是「除法可以存在,但要能说清分母什么时候不为零」。半平面裁剪里的 fa − fb 也是同一个道理:只有异号才调用,异号保证分母不为零。
容差怎么取
叉积和点积都是「长度 × 长度」,量纲是长度平方。判断「点是否在直线上」时,容差不能永远是一个固定的 1e-9,而应该跟坐标尺度走,比如:
const eps = 1e-9 * dmax * dmax;
if (Math.abs(side) <= eps) { /* 认为在直线上 */ }
dmax 是点集包围盒的长边。坐标整体放大 10 倍,容差放大 100 倍,相对强度不变。固定绝对容差在坐标范围变化时,要么太松、要么太紧。例子二里为了方便调用留了 1e-9 的默认值,那只是兜底;真正使用时应该由调用方按点集尺度传入。
这是和叉积量纲对齐的写法:eps 和 side 都是长度平方,比较时不需要额外解释。点定位那段代码用的是 1e-9 * dmax,随尺度走但量纲不严格,属于实现取舍——两种写法都比固定绝对小数稳,但 dmax² 更容易说明「为什么是这个量级」。
更精确一点,容差还会把边的长度算进去:1e-9 * dmax * |pq|。用 dmax² 相当于假设边长与点集尺度同阶。如果点集本身很小(dmax < 1),要不要再给一个绝对下限,取决于数据;加了就不再严格随尺度平方走。
一条可以直接用的规则
能用两个点表示直线,就不要算斜率;必须算交点时,把除法留到最后一刻,并且确认分母在什么条件下不为零。
斜率不是错,它只是把「垂直」这个特殊情况藏进了公式的盲区。几何代码里,特殊情况不会因为你不写就不出现;最好的处理方式是选一个让特殊情况自然消失的表示。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)