平面几何:如何实现圆角矩形转路径?

43 阅读8分钟

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

圆角矩形,其实就是在矩形的基础上在四个角上加上 4 个圆角

属性上相比矩形多了名为 cornerRadius 属性。

在 CSS 中,这个属性叫做 border-radius,可以指定所有圆角的半径,或指定每个圆角的半径,也可以指定为椭圆。CSS 的属性的值的写法也是异常灵活。

在 SVG 中的 rect 元素中,属性是 rx、ry,表达的是所有圆角的 椭圆 半径,不支持指定单个圆角的半径。如果要指定单个圆角,我们在渲染上通常会改为用 path 去拟合。

这里会忽略椭圆角的场景,假设都是圆角。椭圆会有另一套规则,目前我用不到。

圆角修正

指定圆角的值,然后 4 个角就画出对应半径的圆角,看起来一切都运转良好。

图片

但有一个重要的问题,如果矩形宽高也不足以分配半径,该如何处理?

比如宽只有 5,但左上圆角半和右上圆角的半径和超过了 5。

为此需要设计一套规则,在必要的时候修正圆角半径值

这里我选择使用 Figma 的规则

  1. 求某圆角半径的修正值,分别计算对应两边的对应的圆角的半径和,判断是否超出边长度;

  2. 如果没超出边长度,修正值为原值;

  3. 如果超出边长度,则基于比例进行分配,作为修正值。

  4. 取 2 个修正值中最小的一个。

以下图为例,

左上圆角为 5,右上圆角为 4,它们的和为 9,小于等于宽 10,不需要重新计算,修正值为原值 5;

右下角为 6,相加为 11,超过了 10,按比例重新分配 5/(5+6) 乘以变长得到 4.545,作为修正值;

取其中最小值作为最终修正值。

图片

这样就能保证这个圆角在修正后和相邻圆角有完全足够的放置空间。

它有一个缺点,就是一些场景下不能充分利用空间。一些大半径明明看着两边都有空间,但依旧很小。

这是因为相邻圆角过大,强行让当前圆角按比例变小了,但此时相邻圆角可能也因为其他圆角而变形,最后也没有使用这些空间,所以看起来没有被充分利用。

代码实现

/* 计算修正后的圆角半径 */
const calcRenderCornerRadii = (size, radii) => {
const edges = [size.width, size.height, size.width, size.height];

const correctedRadii = [0000];
for (let i0; i < radii.length; i++) {
    const r = radii[i];

    // 半径为 0,其实没啥好修正的,不可能更小了
    if (r === 0) {
      correctedRadii[i] = r;
      continue;
    }

    const prevIndex = (i - 1 + edges.length) % edges.length;
    const nextIndex = (i + 1) % edges.length;
    const edge1 = edges[prevIndex];
    const edge2 = edges[i];
    const r1 = radii[prevIndex];
    const r2 = radii[nextIndex];

    let correctedR1 = r;
    let correctedR2 = r;
    // 计算修正值 1
    if (r1 + r > edge1) {
      correctedR1 = (r / (r1 + r)) * edge1;
    }
    // 计算修正值 2
    if (r2 + r > edge2) {
      correctedR2 = (r / (r2 + r)) * edge2;
    }
    // 取小的
    correctedRadii[i] = Math.min(correctedR1, correctedR2);
  }
return correctedRadii;
};

圆弧拟合

本文讨论的是如何将圆弧转路径(path),下面看下转换的逻辑。

路径为了进行简化,通常是转为直线和三阶贝塞尔曲线的组合,很多图形编辑器都是这样的。

因此我们需要将圆角拟合成三阶贝塞尔(cubic bezier)。

三阶贝塞尔拟合圆弧我之前详细讲解过,这里就不展开讲了,可以看我的这篇文章:

如何用三阶贝塞尔曲线拟合圆形、椭圆、任意圆弧?

这里是 1/4 圆,拟合用到的 K 为 0.5522847498307936,后面我们直接实现根据不同的圆角朝向套公式就好了。

我们用 SVG 的 path 的表达方式,长这样子:

const pathCmds = [
  {
    type'M'// 起始点
    points: [
      {
        x: 0,
        y: 2,
      },
    ],
  },
  {
    type'C'// cubic 三阶贝塞尔
    points: [
      { // 控制点 1
        x: 0,
        y: 0.9,
      },
      { // 控制点 2
        x: 0.9,
        y: 0,
      },
      { // 锚点 2
        x: 2,
        y: 0,
      },
    ],
  },
  {
    type'L'// 直线
    points: [
      {
        x: 6,
        y: 0,
      },
    ],
  },
// ...
  {
    type'Z'// 闭合
    points: [],
  },
];

我们从第一个圆角开始(M),顺时针不断地新增曲线(C)和直线(L),围成最终路径,最后记得用 Z 命令闭合。

图片

这里有两个点注意:

  1. 圆角半径为 0 时,直接跳过;

  2. 直线可能也不需要画,看上一个圆角(修正后)和下一个圆角(修正)的值是否超过当前边,不超过,才需要新增 L 绘制直线命令。

代码实现

const K0.5522847498307936;

const roundRectToPathCmds = (
  rect: IRect,
  cornerRadii: number[] = [],
): IPathCommand[] => {
// 得到修正好的圆角半径值数组
const radii = calcCorrectedCornerRadii(rect, cornerRadii);
const { minX, minY, maxX, maxY } = rectToBox(rect);

const commands: IPathCommand[] = [
    { type: 'M', points: [{ x: minX, y: minY + radii[0] }] },
  ];
// left top
if (radii[0]) {
    commands.push({
      type'C',
      points: [
        { x: minX, y: minY + radii[0] - radii[0] * K },
        { x: minX + radii[0] - radii[0] * K, y: minY },
        { x: minX + radii[0], y: minY },
      ],
    });
  }
// top line (skip if full width)
if (radii[0] + radii[1] < rect.width) {
    commands.push({
      type'L',
      points: [{ x: maxX - radii[1], y: minY }],
    });
  }
// right top
if (radii[1]) {
    commands.push({
      type'C',
      points: [
        { x: maxX - radii[1] + radii[1] * K, y: minY },
        { x: maxX, y: minY + radii[1] - radii[1] * K },
        { x: maxX, y: minY + radii[1] },
      ],
    });
  }
// right line (skip if full height)
if (radii[1] + radii[2] < rect.height) {
    commands.push({
      type'L',
      points: [{ x: maxX, y: maxY - radii[2] }],
    });
  }
// right bottom
if (radii[2]) {
    commands.push({
      type'C',
      points: [
        { x: maxX, y: maxY - radii[2] + radii[2] * K },
        { x: maxX - radii[2] + radii[2] * K, y: maxY },
        { x: maxX - radii[2], y: maxY },
      ],
    });
  }
// bottom line (skip if full width)
if (radii[2] + radii[3] < rect.width) {
    commands.push({
      type'L',
      points: [{ x: minX + radii[3], y: maxY }],
    });
  }
// left bottom
if (radii[3]) {
    commands.push({
      type'C',
      points: [
        { x: minX + radii[3] - radii[3] * K, y: maxY },
        { x: minX, y: maxY - radii[3] + radii[3] * K },
        { x: minX, y: maxY - radii[3] },
      ],
    });
  }
// left line (skip if full height)
if (radii[3] * radii[1] <= rect.height) {
    commands.push({
      type'L',
      points: [{ x: minX, y: minY + radii[0] }],
    });
  }
// close
  commands.push({
    type'Z',
    points: [],
  });

return commands;
};

完整实现

下面是完整的 typeScript 实现。

interface IRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface ISize {
  width: number;
  height: number;
}

interface IBox {
  minX: number;
  minY: number;
  maxX: number;
  maxY: number;
}

const rectToBox = (rect: IRect): IBox => {
return {
    minX: rect.x,
    minY: rect.y,
    maxX: rect.x + rect.width,
    maxY: rect.y + rect.height,
  };
};

interface IPoint {
  x: number;
  y: number;
}

interface IPathCommand {
type: string;
  points: IPoint[];
}

const calcCorrectedCornerRadii = (size: ISize, radii: number[]) => {
const edges = [size.width, size.height, size.width, size.height];

const correctedRadii = [0000];
for (let i0; i < radii.length; i++) {
    const r = radii[i];

    if (r === 0) {
      correctedRadii[i] = r;
      continue;
    }

    const prevIndex = (i - 1 + edges.length) % edges.length;
    const nextIndex = (i + 1) % edges.length;
    const edge1 = edges[prevIndex];
    const edge2 = edges[i];
    const r1 = radii[prevIndex];
    const r2 = radii[nextIndex];

    let correctedR1 = r;
    let correctedR2 = r;

    if (r1 + r > edge1) {
      correctedR1 = (r / (r1 + r)) * edge1;
    }
    if (r2 + r > edge2) {
      correctedR2 = (r / (r2 + r)) * edge2;
    }
    correctedRadii[i] = Math.min(correctedR1, correctedR2);
  }
return correctedRadii;
};

const K0.5522847498307936;

const roundRectToPathCmds = (
  rect: IRect,
  cornerRadii: number[] = [],
): IPathCommand[] => {
const radii = calcCorrectedCornerRadii(rect, cornerRadii);

const { minX, minY, maxX, maxY } = rectToBox(rect);

const commands: IPathCommand[] = [
    { type: 'M', points: [{ x: minX, y: minY + radii[0] }] },
  ];
// left top
if (radii[0]) {
    commands.push({
      type: 'C',
      points: [
        { x: minX, y: minY + radii[0] - radii[0] * K },
        { x: minX + radii[0] - radii[0] * K, y: minY },
        { x: minX + radii[0], y: minY },
      ],
    });
  }
// top line (skip if full width)
if (radii[0] + radii[1] < rect.width) {
    commands.push({
      type: 'L',
      points: [{ x: maxX - radii[1], y: minY }],
    });
  }
// right top
if (radii[1]) {
    commands.push({
      type: 'C',
      points: [
        { x: maxX - radii[1] + radii[1] * K, y: minY },
        { x: maxX, y: minY + radii[1] - radii[1] * K },
        { x: maxX, y: minY + radii[1] },
      ],
    });
  }
// right line (skip if full height)
if (radii[1] + radii[2] < rect.height) {
    commands.push({
      type: 'L',
      points: [{ x: maxX, y: maxY - radii[2] }],
    });
  }
// right bottom
if (radii[2]) {
    commands.push({
      type: 'C',
      points: [
        { x: maxX, y: maxY - radii[2] + radii[2] * K },
        { x: maxX - radii[2] + radii[2] * K, y: maxY },
        { x: maxX - radii[2], y: maxY },
      ],
    });
  }
// bottom line (skip if full width)
if (radii[2] + radii[3] < rect.width) {
    commands.push({
      type: 'L',
      points: [{ x: minX + radii[3], y: maxY }],
    });
  }
// left bottom
if (radii[3]) {
    commands.push({
      type: 'C',
      points: [
        { x: minX + radii[3] - radii[3] * K, y: maxY },
        { x: minX, y: maxY - radii[3] + radii[3] * K },
        { x: minX, y: maxY - radii[3] },
      ],
    });
  }
// left line (skip if full height)
if (radii[3] * radii[1] <= rect.height) {
    commands.push({
      type: 'L',
      points: [{ x: minX, y: minY + radii[0] }],
    });
  }
// close
  commands.push({
    type: 'Z',
    points: [],
  });

return commands;
};

用法:

const pathCmds = roundRectToPathCmds(
  { x0, y0, width10, height10 },
  [5406],
);

看看效果

图片

结尾

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


相关阅读,

贝塞尔曲线算法:求贝塞尔曲线的包围盒

贝塞尔曲线是什么?如何用 Canvas 绘制三阶贝塞尔曲线?

贝塞尔曲线:求点到贝塞尔曲线的投影