平面几何:求直线线段的轮廓线

2024-07-31 19:59:24 浏览数 (2)

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

今天我们来学习简单的平面几何算法,求直线线段的轮廓线。

需求是给两个点表达的直线线段,以及线宽,求它的轮廓线多边形

对于直线线段,末端有三种样式:

  1. Butt:平端,不增加额外形状;
  2. Square:方形端,额外补充一个矩形,宽为线宽,高为线宽的一半;
  3. Round:圆形端,额外补充一个半圆,半径为线宽的一半。

本文实现算法的在线交互 Demo:

https://codepen.io/F-star/pen/PorzxLw

Butt(平端)

我们先看看平端的实现。

求线段的法向量,乘以线宽的一半,得到位移向量。然后让线段的两个点分别做两个方向的位移,得到多边形的 4 个顶点,将它们按照一定顺序连接起来得到多边形,这个多边形就是我们要求的轮廓多边形。

求法向量,其实就是计算向量 p1-p2 旋转 90 度。旋转的方向没关系,计算出的法向量有两个方向,都可以,只要点的顺序。

将一个向量旋转 90 度,可以用三角函数推导,或者直接用旋转矩阵,具体推导就不做了。

有个特殊的规律:对于向量旋转 90 度的向量,我们只需要把 x 和 y 交换位置,然后将其中一个值取反。

代码语言:javascript复制
x2 = y;
y2 = -x;

或者你可以点积的角度看,互相垂直的两条向量的点积总是零

代码语言:javascript复制
// 法向量,模长为线段长度
const tan = {
  x: p2.x - p1.x,
  y: p2.y - p1.y,
};
// 线性插值比率 t
const t = width / 2 / Math.sqrt(tan.x * tan.x   tan.y * tan.y);
// 求法向量,模长为 width /2
const normal = {
  x: tan.y * t,
  y: -tan.x * t,
};

最后依次算出 4 个顶点。

代码语言:javascript复制
const vertexes = [
  { x: p1.x   normal.x, y: p1.y   normal.y },
  { x: p2.x   normal.x, y: p2.y   normal.y },
  { x: p2.x - normal.x, y: p2.y - normal.y },
  { x: p1.x - normal.x, y: p1.y - normal.y },
];

完整代码:

代码语言:javascript复制
const outlineLineWithButtCap = (p1: Point, p2: Point, width: number) => {
  const tan = {
    x: p2.x - p1.x,
    y: p2.y - p1.y,
  };
  const t = width / 2 / Math.sqrt(tan.x * tan.x   tan.y * tan.y);
  // 求法向量,模长为 width /2
  const normal = {
    x: tan.y * t,
    y: -tan.x * t,
  };

  const vertexes = [
    { x: p1.x   normal.x, y: p1.y   normal.y },
    { x: p2.x   normal.x, y: p2.y   normal.y },
    { x: p2.x - normal.x, y: p2.y - normal.y },
    { x: p1.x - normal.x, y: p1.y - normal.y },
  ];

  return vertexes;
};

看看渲染效果,很完美。

Square(方形端)

Square 方形端,需要额外补充一个矩形,宽为线宽,高为线宽的一半。

观察就能发现,Square 等价于让直线两端往两测延长 “线宽一半” 的长度,然后应用 butt 的算法。

代码语言:javascript复制
const outlineLineWithSquareCap = (
  p1: Point,
  p2: Point,
  width: number,
) => {
  const tan = {
    x: p2.x - p1.x,
    y: p2.y - p1.y,
  };
  const t = width / 2 / Math.sqrt(tan.x * tan.x   tan.y * tan.y);

  // 需要位移的距离
  const dx = tan.x * t;
  const dy = tan.y * t;

  p2 = {
    x: p2.x   dx,
    y: p2.y   dy,
  };

  p1 = {
    x: p1.x - dx,
    y: p1.y - dy,
  };

  // 使用 butt 的算法
  return outlineLineWithButtCap(p1, p2, width);
};

渲染结果:

Round(圆形端)

最后是圆形端,需要给末端额外加半圆,圆半径为线宽的一半。

我们要求的是多边形,其实也就是在 butt 求出的 4 个顶点的基础上,再插入两个圆弧。

其实圆弧很容易确定,我们已经知道每个圆弧的两个端点,还有半径。

但麻烦的点在于我们需要用某种方式表达这个圆弧,圆弧的表达有好几种,且有点复杂,不同渲染引擎支持的圆弧表达是不一样的,这代表我们可能要在多种表达中进行转换。()

常见的圆弧表达有三种:

  1. 圆心、半径 、起始角、结束角、方向;
  2. 起点、终点、半径、优弧、方向;
  3. 起点、终点、凸度;

这三种表达我在之前的文章详细讲解过,感兴趣可以 前往阅读

我们需要在里面选择一种。

这段圆弧是作为多段线的一部分,用带有起点、终点的表达会更好些,再考虑到能够无缝使用 SVG 的 Path 元素表达,最终我们选择用第二种方案:起点、终点、半径、优弧(largeArc)、方向(sweep)

起点、终点、半径我们都已经有了,我们需要确定优弧(是否使用大的弧)和方向。

因为是半圆,所以优弧是 true 还是 false 并无所谓,它们对应的两个圆会重叠为一个圆,这里我们取 true。

虽然在计算 butt 时,法向量的方向无关紧要,但对于 round 末端效果还是有影响的。y 取反的法向量,对应的多边形的方向是顺时针,圆弧自然也需要是顺时针,所以方向(sweep)为 true。

完整代码实现:

代码语言:javascript复制
const outlineLineWithRoundCap = (
  p1: Point,
  p2: Point,
  width: number,
) => {
  const buttOutline = outlineLineWithButtCap(p1, p2, width);

  return [
    buttOutline[0],
    buttOutline[1],
    {
      ...buttOutline[2],
      radius: width / 2,
      largeArc: true,
      sweep: true,
    },
    buttOutline[3],
    {
      ...buttOutline[0],
      radius: width / 2,
      largeArc: true,
      sweep: true,
    },
  ];
};

SVG 的 Path 元素的 d 属性值为:

代码语言:javascript复制
// 起点
let d = `M ${outline[0].x} ${outline[0].y} `;

for (let i = 1; i < outline.length; i  ) {
  const seg = outline[i];
  if ('radius' in seg) {
    // 圆弧
    d  = `A ${seg.radius} ${seg.radius} 0 ${seg.largeArc ? 1 : 0} ${
      seg.sweep ? 1 : 0
    } ${seg.x} ${seg.y} `;
  } else {
    // 直线
    d  = `L ${seg.x} ${seg.y} `;
  }
}

// 闭合
d  = 'Z';

渲染结果:

三种效果对比

我们将这三种算法得到的多边形同时渲染,对比一下效果。

结尾

这次的算法还是挺简单的,总结一下,就是 求法向量,把直线的两个端点往两侧位移一下,得到一个矩形多边形,然后根据末端样式,给两边补上矩形或半圆

末端样式是可以做自定义扩展,补上任意你想要的图形的。

比如我给某一端补上一个三角形,就变成了什么?变成了一个箭头线。

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

0 人点赞