前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >在两条直线相交处添加圆角,算法该如何实现?

在两条直线相交处添加圆角,算法该如何实现?

作者头像
前端西瓜哥
发布2024-06-17 13:38:08
600
发布2024-06-17 13:38:08
举报

大家好,我是前端西瓜哥。

下面我们看一个平面几何算法。

已知两条直线形成的折线,和圆角的半径,求在两条直线相交位置添加该圆角后的形状。

如图:

思路

思路非常简单。

将两条直线 往中间位置偏移半径的距离,偏移后的两条直线的 交点就是圆角的圆心

然后基于圆心作两条直线的垂足得到两个点,这两个点就是圆弧起点和终点,然后确定方向就可以了。

Demo 效果演示:

关注公众号,后台回复 “加圆角”,获取在线 demo 地址

实现

我们用两个点表示一条直线。

直线 1 用点 p1、p2 表示,直线 2 用点 p3、p4 表示,圆角半径为 radius。

代码语言:javascript
复制
 const calcRoundCorner = (
  p1: Point,
  p2: Point,
  p3: Point,
  p4: Point,
  radius: number,
) => {
  // ...
}

求偏移直线

我们需要知道两条直线的左右关系,为此我们需要计算两条直线对应向量的叉积。

叉积的作用是判断向量的左右关系。

代码语言:javascript
复制
// 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,说明两条直线平行或共线,无法确定圆心位置,没有意义,直接结束返回。

代码语言:javascript
复制
if (cp === 0) {
  // 平行,无法生成圆角
  return null;
}

如果叉积小于 0,说明 v2 在 v1 的左边(注意这里的左边指的是向量方向前进方向的左边,不是布局的左边)。

所以中间位置在 v1 的左边,v2 的右边。

v1 对应的直线就需要向左边移动半径距离。

我们求出 v1 的向左法向量,然后让它的模长为半径长度,得到位移向量。

代码语言:javascript
复制
// 求 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 的右边,和前面的区别就是法向量反过来,其它都是一样的。

求圆心

前面我们得到了偏移后的两条直线,就可以用解方程的方式求两条直线的圆心了。

这个我之前的文章讲过,这里直接给求两直线交点的代码实现:

代码语言:javascript
复制
/**
 * 求两条直线交点
 */
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 };
};

所以圆角的圆心为:

代码语言:javascript
复制
// 求偏移后两条直线的交点,这个交点就是圆心
const circleCenter = getLineIntersection(
  offsetLine1[0],
  offsetLine1[1],
  offsetLine2[0],
  offsetLine2[1],
);

求垂足

然后我们将圆心往两条直线上投影,求垂足点,这两个点是圆弧的起点和终点。

这个投影,或者说找到直线的最近点算法,我之前的文章也讲过,这里也直接贴代码实现:

代码语言:javascript
复制
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,
  };
};

求出圆弧起点和终点:

代码语言:javascript
复制
// 求圆心到两条线的垂足
const { point: start } = closestPointOnLine(p1, p2, circleCenter, true);
const { point: end } = closestPointOnLine(p3, p4, circleCenter, true);

然后就是圆弧反向,基于叉积的正负值可得出。

代码语言:javascript
复制
const angleDir = cp < 0, // 正值 -> 顺时针

确定圆弧和收尾工作

至此我们知道了 圆心、半径、起点、终点、方向,圆弧就能确定了

后续我们只需要将这些圆弧的信息转换为渲染引擎支持的数据结构,常见的有三种

最后可能要调整一下线段的端点位置,使其落在圆弧端点上。

扩展点

有几个扩展点。

首先是对于 圆角半径大小的限制 的考虑。

一般情况下,圆角圆弧的端点不会超出两条线段的范围。

但特殊情况下还是会超出的:设置一个很大的圆角半径。

AutoCAD 的做法是,提示 “圆角半径太大”,不允许生成。

Figma 的做法是,会使用圆角效果,但实际渲染时的 radius 不能超出某个值,保证圆弧的端点不超出线段区间。

不管哪种方案,都要求一下两条线段各自能支持的最大圆角半径,取其中较小的,作为阈值。

可以用点积求出夹角,然后用三角函数求出支持最大圆角半径:

曲线也能做相交处圆角,原理还是一样的,曲线同样也是向中间位置偏移一段距离,接着求圆角中点,然后就是求到两条线的垂足。

完整代码实现

因为还带上了一些子算法,所以代码有一点点长。

代码语言:javascript
复制
// 求两直线的圆角圆弧
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;
};

结尾

我是前端西瓜哥,欢迎关注我,学习更多平面几何知识。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-06-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端西瓜哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 思路
  • 实现
    • 求偏移直线
      • 求圆心
        • 求垂足
          • 确定圆弧和收尾工作
          • 扩展点
          • 完整代码实现
          • 结尾
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档