外接圆画成了椭圆:非均匀缩放下的空圆性错觉
圆没有穿过三个顶点
绘制区是 1144×764,模型坐标按 1000×1000 设计,映射关系就是 x 乘 1.144、y 乘 0.764。这里的 1144×764 指 canvas.width/height,不是 CSS 尺寸;两者不一致时浏览器还会再缩放一次,sx、sy 必须以 backing store 为准。取三点 A(180,140)、B(820,260)、C(500,720),实算外接圆心为 (470.361, 358.077),半径 363.135。映射到屏幕,圆心落在 (538.093, 273.571)。如果只把半径乘上 x 方向的 1.144,得到 415.426;三个顶点到屏幕圆心的距离分别是 371.615、406.945、278.580。
这三个距离没有一个等于 415.426。A 差 43.811,B 差 8.481,C 差 136.846(均为半径减顶点距离)。C 靠近画布下方,y 方向被压到 0.764,径向差最大。屏幕上看到的是一个偏高的正圆:它比应有的椭圆在上下方向高出一截,三个顶点全落在弧内,空圆性像是被破坏了。
空圆性的画面含义很直接:外接圆应当同时穿过三个顶点,三角形内部不被圆弧切到。现在三个顶点都在弧内,圆弧没有经过任何一个顶点,外接圆的定义在画面上就不成立,和 Delaunay 的判定看起来冲突。实际数据没有冲突,冲突只发生在屏幕空间。
这类 bug 容易被误判为浮点误差。公式本身的浮点误差远小于一个像素,屏幕上的偏差却是 136.846 像素;看到顶点不贴合时,先量偏差,不要先调 epsilon。只要偏差随画布尺寸变化,基本就是坐标变换问题,而不是几何算错了。
外接圆本身没有算错。公式和代入结果如下:
function circumcircle(a, b, c) {
const d = 2 * (a.x*(b.y-c.y) + b.x*(c.y-a.y) + c.x*(a.y-b.y));
const A = a.x*a.x + a.y*a.y, B = b.x*b.x + b.y*b.y, C = c.x*c.x + c.y*c.y;
const cx = (A*(b.y-c.y) + B*(c.y-a.y) + C*(a.y-b.y)) / d;
const cy = (A*(c.x-b.x) + B*(a.x-c.x) + C*(b.x-a.x)) / d;
return { cx, cy, r: Math.hypot(a.x-cx, a.y-cy) };
}
代入三点,d=665600,圆心与半径就是上面的数。d 是叉积的两倍,三点共线时 d=0,实际代码要在除法前把这种情况挡掉。圆心和半径都在模型坐标里,下一步才是问题所在。
一个标量半径画不出压扁的圆
模型圆上的点可以写成 (r cosθ, r sinθ)。经过屏幕映射后变成 (1.144r cosθ, 0.764r sinθ),这是一条半轴为 415.426 和 277.435 的椭圆,不是圆。ctx.arc 的参数只有一个半径,无论取 1.144r、0.764r 还是两者的平均 0.954r,都只能得到正圆。取平均时,三个顶点的偏差是 +25.185、+60.515、-67.850(此处偏差口径为顶点距离减半径,与上一段的半径减顶点距离符号相反),仍然没有零。只要 sx 与 sy 不相等,圆心固定后,单半径就无解。当 sx=sy 时,rx=ry,ellipse 退化成 arc,两条路径重合;这也是为什么在正方形画布上测不出这个问题,测试用例里至少要有一个非等比视口。
正确做法是把两个方向的缩放分别放进半轴:
const A = {x:180, y:140}, B = {x:820, y:260}, C = {x:500, y:720};
const { cx, cy, r } = circumcircle(A, B, C);
const SX = 1144 / 1000, SY = 764 / 1000;
const X = cx * SX, Y = cy * SY;
ctx.beginPath();
ctx.arc(X, Y, r * SX, 0, Math.PI * 2); // 只有一个半径
ctx.stroke();
ctx.beginPath();
ctx.ellipse(X, Y, r * SX, r * SY, 0, 0, Math.PI * 2); // 两个半轴
ctx.stroke();
把三个顶点代进椭圆方程 (dx/415.426)²+(dy/277.435)²,结果都是 1.000000000,打印精度内没有偏差。画布上的“圆”本来就该是椭圆;ellipse 的旋转参数为 0,是因为两条轴仍然与画布轴平行。
还有一个更便宜的旁证:路径包围盒。arc 版本是 830.852×830.852 的正方形,ellipse 版本是 830.852×554.870。包围盒不能证明圆过三点,但能立刻发现两个方向被画成了同一个尺度。若不想改绘制调用,也可以先把缩放压进上下文:ctx.setTransform(SX, 0, 0, SY, 0, 0) 之后用模型坐标 ctx.arc(cx, cy, r, ...),效果等价于椭圆;代价是描边宽度也会被各向异性缩放,线条在水平和垂直方向不一样粗,所以画圆时仍以显式 ellipse 更可控。
这里还牵出一个渲染层的数据问题:如果渲染对象只保存一个 r,命中测试、描边宽度换算、后续的圆内点位采样都会跟着用错。把屏幕空间的 rx、ry 一起算出来并保存,比在下游反复乘缩放因子可靠。
命中测试也常犯同一个错:拿鼠标点到圆心的距离和一个半径比。两轴比例不同时,应该用椭圆方程 (dx/rx)²+(dy/ry)² <= 1,或者把点乘逆矩阵送回模型空间再比 r;单半径判断会在一个方向偏松、另一个方向偏紧。
断言停在模型空间,bug 在绘制层
用面积和边集合做的断言不会碰到这个问题。它们读的是模型坐标里的点、三角形面积、三条边的长度和半径,全部在 ctx 之前完成。空圆性检查也是把点代回距离公式;三个顶点到圆心的距离都等于 363.135,当然通过。绘制层把圆压成椭圆,对这些断言不可见:测试全绿,画面仍然错。断言和渲染共用同一份几何,却没有共用同一套坐标变换,盲区就出在这里。
模型空间的空圆断言通常写成 (px-cx)²+(py-cy)² 与 r² 比较,三个顶点代进去相等,边界用 <= 也过。它验证的是几何关系,不是绘制结果;即便再加第四点检查,仍然不会经过 ctx。要覆盖绘制,必须让检查读到路径或像素。
要抓住它,得从屏幕坐标反变换回模型坐标再量距离。对路径按角度采样,把每个采样点 (px,py) 用 ((px-X)/SX, (py-Y)/SY) 送回模型空间,计算它到圆心的距离,与 r 比较:
function maxRadialError(sample, X, Y, SX, SY, r) {
let max = 0;
for (let i = 0; i < 720; i++) {
const t = i * Math.PI / 360;
const p = sample(t);
const dx = (p[0] - X) / SX, dy = (p[1] - Y) / SY;
max = Math.max(max, Math.abs(Math.hypot(dx, dy) - r));
}
return max;
}
对 arc 路径采样 720 点,最大偏差 180.617,出现在 90° 方向;对 ellipse 路径,最大偏差 1.705e-13。阈值取 1e-6 就能分开两者。这个检查不看截图,也不依赖这一组三角形,换点仍然成立。
如果只检查截图,抗锯齿会让边缘发虚,上百像素的偏差当然明显,但几像素的偏差容易放过;反变换检查量的是路径本身,不受抗锯齿影响。
检查器本身也要防止空转:如果 sample 总是返回同一个点,最大偏差会是一个常数,看起来也可能“通过”。采样至少要覆盖 0°、90°、180°、270° 四个方向,并确认样本数大于 1;这组参数下 90° 的单点误差就是 180.617,已经足够暴露问题。
这类偏差还有一个特征:它不是随机抖动,而是随角度平滑变化。把 720 个采样点的误差画出来,arc 的曲线在 90° 和 270° 两个方向达到峰值,正好是 y 轴被压得最狠的方向;ellipse 则贴着零线。真出现回归时,从误差曲线就能判断是缩放矩阵没传对,还是半径取错了轴。
缩放比也不是常量。窗口尺寸一变,1.144 和 0.764 都会变;把这两个数写死在绘制代码里,换一个画布尺寸就会重新出现偏差。稳妥的做法是从当前视口和模型范围算出 sx、sy,绘制时用 rx=r*sx、ry=r*sy,检查时用同一组比值做反变换。最大偏差与半径同阶,这组参数下是 180.617;半径翻倍,偏差也会翻倍,所以不能用固定像素阈值去兜。
修复分三步:把 sx、sy 作为绘制状态的一部分,和模型到屏幕的平移一起保存;画外接圆时用 rx=r*sx、ry=r*sy;检查器接收同一组 sx、sy,而不是自己再推一遍。任何一步换了缩放来源,误差都会回来。
如果视口还带平移或旋转,做法一样:把模型到屏幕的 2×3 矩阵存下来,采样点乘逆矩阵回到模型空间;椭圆的两个半轴和 rotation 也从矩阵分解,不要手工填。ellipse 的 rotation 在轴对齐时是 0,画布旋转后应取模型 x 轴在屏幕上的角度,只改 rx、ry 不改 rotation 仍然不贴合。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)