大家好,我是前端西瓜哥。
下面我们看一个平面几何算法。
已知两条直线形成的折线,和圆角的半径,求在两条直线相交位置添加该圆角后的形状。
如图:
思路非常简单。
将两条直线 往中间位置偏移半径的距离,偏移后的两条直线的 交点就是圆角的圆心。
然后基于圆心作两条直线的垂足得到两个点,这两个点就是圆弧起点和终点,然后确定方向就可以了。
Demo 效果演示:
关注公众号,后台回复 “加圆角”,获取在线 demo 地址
我们用两个点表示一条直线。
直线 1 用点 p1、p2 表示,直线 2 用点 p3、p4 表示,圆角半径为 radius。
const calcRoundCorner = (
p1: Point,
p2: Point,
p3: Point,
p4: Point,
radius: number,
) => {
// ...
}
我们需要知道两条直线的左右关系,为此我们需要计算两条直线对应向量的叉积。
叉积的作用是判断向量的左右关系。
// p2 到 p1 向量
const v1 = {
x: p1.x - p2.x,
y: p1.y - p2.y,
};
// p2 到 p3 的向量
const v2 = {
x: p4.x - p3.x,
y: p4.y - p3.y,
};
// 叉积
const cp = v1.x * v2.y - v2.x * v1.y;
注意,这里我们假设坐标系 x 轴向右,y 轴向下。
如果叉积为 0,说明两条直线平行或共线,无法确定圆心位置,没有意义,直接结束返回。
if (cp === 0) {
// 平行,无法生成圆角
return null;
}
如果叉积小于 0,说明 v2 在 v1 的左边(注意这里的左边指的是向量方向前进方向的左边,不是布局的左边)。
所以中间位置在 v1 的左边,v2 的右边。
v1 对应的直线就需要向左边移动半径距离。
我们求出 v1 的向左法向量,然后让它的模长为半径长度,得到位移向量。
// 求 v1 向左法向量(这里不是单位向量)
normalVec1 = {
x: v1.y,
y: -v1.x,
};
const t1 = radius / distance(p1, p2);
// 算出位移向量
const d = {
x: normalVec1.x * t1,
y: normalVec1.y * t1,
};
// line1 沿法向量偏移半径长度
const offsetLine2 = [
{
x: p3.x + d2.x,
y: p3.y + d2.y,
},
{
x: p4.x + d2.x,
y: p4.y + d2.y,
},
];
求一个向量的法向量其实就是将该向量旋转 90 度或 -90 度,结果是 x 和 y 交换位置,且其中一个符号取反。
向左的法向量对应的旋转 -90度,这里可以考虑引入矩阵库数学工具,使用旋转矩阵提高代码的可读性。
同理,v2 对应的直线就需要向右移动半径距离,这里不再赘述。
如果叉积大于 0,说明 v2 在 v1 的右边,和前面的区别就是法向量反过来,其它都是一样的。
前面我们得到了偏移后的两条直线,就可以用解方程的方式求两条直线的圆心了。
这个我之前的文章讲过,这里直接给求两直线交点的代码实现:
/**
* 求两条直线交点
*/
export const getLineIntersection = (
p1: Point,
p2: Point,
p3: Point,
p4: Point,
): Point | null => {
const { x: x1, y: y1 } = p1;
const { x: x2, y: y2 } = p2;
const { x: x3, y: y3 } = p3;
const { x: x4, y: y4 } = p4;
const a = y2 - y1;
const b = x1 - x2;
const c = x1 * y2 - x2 * y1;
const d = y4 - y3;
const e = x3 - x4;
const f = x3 * y4 - x4 * y3;
// 计算分母
const denominator = a * e - b * d;
// 判断分母是否为 0(代表平行)
if (Math.abs(denominator) < 0.000000001) {
// 这里有个特殊的重叠但只有一个交点的情况,可以考虑处理一下
return null;
}
const px = (c * e - f * b) / denominator;
const py = (a * f - c * d) / denominator;
return { x: px, y: py };
};
所以圆角的圆心为:
// 求偏移后两条直线的交点,这个交点就是圆心
const circleCenter = getLineIntersection(
offsetLine1[0],
offsetLine1[1],
offsetLine2[0],
offsetLine2[1],
);
然后我们将圆心往两条直线上投影,求垂足点,这两个点是圆弧的起点和终点。
这个投影,或者说找到直线的最近点算法,我之前的文章也讲过,这里也直接贴代码实现:
const closestPointOnLine = (
p1: Point,
p2: Point,
p: Point,
/** 是否限制在在线段之内 */
canOutside = false,
) => {
if (p1.x === p2.x && p1.y === p2.y) {
return {
t: 0,
d: distance(p1, p),
point: { x: p1.x, y: p1.y },
};
}
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
let t = ((p.x - p1.x) * dx + (p.y - p1.y) * dy) / (dx * dx + dy * dy);
if (!canOutside) {
t = Math.max(0, Math.min(1, t));
}
const closestPt = {
x: p1.x + t * dx,
y: p1.y + t * dy,
};
return {
t,
d: distance(p, closestPt),
point: closestPt,
};
};
求出圆弧起点和终点:
// 求圆心到两条线的垂足
const { point: start } = closestPointOnLine(p1, p2, circleCenter, true);
const { point: end } = closestPointOnLine(p3, p4, circleCenter, true);
然后就是圆弧反向,基于叉积的正负值可得出。
const angleDir = cp < 0, // 正值 -> 顺时针
至此我们知道了 圆心、半径、起点、终点、方向,圆弧就能确定了。
后续我们只需要将这些圆弧的信息转换为渲染引擎支持的数据结构,常见的有三种。
最后可能要调整一下线段的端点位置,使其落在圆弧端点上。
有几个扩展点。
首先是对于 圆角半径大小的限制 的考虑。
一般情况下,圆角圆弧的端点不会超出两条线段的范围。
但特殊情况下还是会超出的:设置一个很大的圆角半径。
AutoCAD 的做法是,提示 “圆角半径太大”,不允许生成。
Figma 的做法是,会使用圆角效果,但实际渲染时的 radius 不能超出某个值,保证圆弧的端点不超出线段区间。
不管哪种方案,都要求一下两条线段各自能支持的最大圆角半径,取其中较小的,作为阈值。
可以用点积求出夹角,然后用三角函数求出支持最大圆角半径:
曲线也能做相交处圆角,原理还是一样的,曲线同样也是向中间位置偏移一段距离,接着求圆角中点,然后就是求到两条线的垂足。
因为还带上了一些子算法,所以代码有一点点长。
// 求两直线的圆角圆弧
const calcRoundCorner = (
p1: Point,
p2: Point,
p3: Point,
p4: Point,
radius: number,
) => {
// p2 到 p1 向量
const v1 = {
x: p1.x - p2.x,
y: p1.y - p2.y,
};
// p2 到 p3 的向量
const v2 = {
x: p4.x - p3.x,
y: p4.y - p3.y,
};
// 求叉积
const cp = v1.x * v2.y - v2.x * v1.y;
if (cp === 0) {
// 平行,无法生成圆角
return null;
}
let normalVec1: Point;
let normalVec2: Point;
// v2 在 v1 的左边
if (cp < 0) {
// 求 v1 向左法向量
normalVec1 = {
x: v1.y,
y: -v1.x,
};
// 求 v2 向右法向量
normalVec2 = {
x: -v2.y,
y: v2.x,
};
}
// v2 在 v1 的右边
else {
normalVec1 = {
x: -v1.y,
y: v1.x,
};
normalVec2 = {
x: v2.y,
y: -v2.x,
};
}
// 求沿法向量偏移半径长度的 line1
const t1 = radius / distance(p1, p2);
const d = {
x: normalVec1.x * t1,
y: normalVec1.y * t1,
};
const offsetLine1 = [
{
x: p1.x + d.x,
y: p1.y + d.y,
},
{
x: p2.x + d.x,
y: p2.y + d.y,
},
];
// 求沿法向量偏移半径长度的 line1
const t2 = radius / distance(p3, p4);
const d2 = {
x: normalVec2.x * t2,
y: normalVec2.y * t2,
};
const offsetLine2 = [
{
x: p3.x + d2.x,
y: p3.y + d2.y,
},
{
x: p4.x + d2.x,
y: p4.y + d2.y,
},
];
// 求偏移后两条直线的交点,这个交点就是圆心
const circleCenter = getLineIntersection(
offsetLine1[0],
offsetLine1[1],
offsetLine2[0],
offsetLine2[1],
)!;
// 求圆心到两条线的垂足
const { point: start } = closestPointOnLine(p1, p2, circleCenter, true);
const { point: end } = closestPointOnLine(p3, p4, circleCenter, true);
// 圆心到垂足的弧度
const angleBase = { x: 1, y: 0 };
const startAngle = getSweepAngle(angleBase, {
x: start.x - circleCenter.x,
y: start.y - circleCenter.y,
});
const endAngle = getSweepAngle(angleBase, {
x: end.x - circleCenter.x,
y: end.y - circleCenter.y,
});
return {
offsetLine1,
offsetLine2,
circleCenter,
start,
end,
startAngle,
endAngle,
angleDir: cp < 0, // 正 -> 顺时针
};
};
// 求两点距离
const distance = (p1: Point, p2: Point) => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return Math.sqrt(dx * dx + dy * dy);
};
// 求两直线交点
const getLineIntersection = (
p1: Point,
p2: Point,
p3: Point,
p4: Point,
): Point | null => {
const { x: x1, y: y1 } = p1;
const { x: x2, y: y2 } = p2;
const { x: x3, y: y3 } = p3;
const { x: x4, y: y4 } = p4;
const a = y2 - y1;
const b = x1 - x2;
const c = x1 * y2 - x2 * y1;
const d = y4 - y3;
const e = x3 - x4;
const f = x3 * y4 - x4 * y3;
// 计算分母
const denominator = a * e - b * d;
// 判断分母是否为 0(代表平行)
if (Math.abs(denominator) < 0.000000001) {
// 这里有个特殊的重叠但只有一个交点的情况,可以考虑处理一下
return null;
}
const px = (c * e - f * b) / denominator;
const py = (a * f - c * d) / denominator;
return { x: px, y: py };
};
// 到直线的最近点(或投影)
const closestPointOnLine = (
p1: Point,
p2: Point,
p: Point,
/** 是否限制在在线段之内 */
canOutside = false,
) => {
if (p1.x === p2.x && p1.y === p2.y) {
return {
t: 0,
d: distance(p1, p),
point: { x: p1.x, y: p1.y },
};
}
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
let t = ((p.x - p1.x) * dx + (p.y - p1.y) * dy) / (dx * dx + dy * dy);
if (!canOutside) {
t = Math.max(0, Math.min(1, t));
}
const closestPt = {
x: p1.x + t * dx,
y: p1.y + t * dy,
};
return {
t,
d: distance(p, closestPt),
point: closestPt,
};
};
/**
* 求向量 a 到向量 b 扫过的夹角
* 这里假设为 x时针方向为正
*/
export const getSweepAngle = (a: Point, b: Point) => {
// 使用点乘求夹角
const dot = a.x * b.x + a.y * b.y;
const d = Math.sqrt(a.x * a.x + a.y * a.y) * Math.sqrt(b.x * b.x + b.y * b.y);
let cosTheta = dot / d;
// 修正精度问题导致的 cosTheta 超出 [-1, 1] 的范围
// 导致 Math.acos(cosTheta) 的结果为 NaN
if (cosTheta > 1) {
cosTheta = 1;
} else if (cosTheta < -1) {
cosTheta = -1;
}
let theta = Math.acos(cosTheta);
// 通过叉积判断方向
// 如果 b 在 a 的左边,则取负值
if (a.x * b.y - a.y * b.x < 0) {
theta = -theta;
}
return theta;
};
我是前端西瓜哥,欢迎关注我,学习更多平面几何知识。