AntV-G-Path 的实现和拾取

简介

path 是 G适应性最广的图形,其绘制和拾取都需要特别处理,本章介绍Path 的绘制和拾取,同时介绍一些优化方案。

SVG Path 简介

G  中支持的 Path 符合 SVG 标准,支持的命令有:

  • M = move to
  • L = line to
  • H = horizontal line to
  • V = vertical line to
  • C = curve to
  • S = smooth curve to
  • Q = quadratic Belzier curve
  • T = smooth quadratic Belzier curve to
  • A = elliptical Arc
  • Z = closepath

 

其中一些命令可以相互转换:

  • H、V  可以转换成 L
  • S 可以转换成 C
  • T 可以转换成 Q

这些命令都有小写形式,表示相对路径:m,l,h,v,c,s,q,t,a,z

更多相关的资料可以参考:

 

G 中的实现

G 中的 Path 图形支持 SVG 的所有的命令,大小写都支持,但是在内部对所有的命令都进行了处理:

  • 将所有小写相对的路径都改成绝对路径
  • 仅保留 M 、L、C、Q、A 和 Z 这 6 种命令,其他命令做响应的转换

 

Path 的命令和Canvas 的对应方法

大多数命令都有直接的对应方法:

  • M 对应 moveTo
  • L 对应 lineTo
  • C 对应 bezierCurveTo
  • Q 对应 quadraticCurveTo
  • Z 对应 closePath

但是 A 命令并没有对应直接的 Canvas 方法, A 命令本质上是绘制可旋转的椭圆,我们有几种方案可以绘制 A:

  • 使用贝塞尔曲线模拟实现,但是不是很精准
  • 有些浏览器支持 ellipse 接口
 ctx.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise); 
    • 这个接口存在兼容性问题
    • 需要将终止的 起始点和终点转换成 startAngle  和 endAngle
  • 使用 arc 绘制圆弧的接口进行模拟
const r = rx > ry ? rx : ry;
const scaleX = rx > ry ? 1 : rx / ry;
const scaleY = rx > ry ? ry / rx : 1;
context.translate(cx, cy);
context.rotate(xRotation);
context.scale(scaleX, scaleY);
context.arc(0, 0, r, startAngle, endAngle, 1 - sweepFlag);
context.scale(1 / scaleX, 1 / scaleY);
context.rotate(-xRotation);
context.translate(-cx, -cy);
    • 平移、旋转、压缩 绘制后再恢复

绘制实现

所以最终来绘制 path 的代码如下:

function drawPath(context, path, arcParamsCache) {
  let currentPoint = [0, 0]; // 当前图形
  let startMovePoint = [0, 0]; // 开始 M 的点,可能会有多个
  context.beginPath();
  for (let i = 0; i < path.length; i++) {
    const params = path[i];
    const command = params[0];
    // V,H,S,T 都在前面被转换成标准形式
    switch (command) {
      case 'M':
        context.moveTo(params[1], params[2]);
        startMovePoint = [params[1], params[2]];
        break;
      case 'L':
        context.lineTo(params[1], params[2]);
        break;
      case 'Q':
        context.quadraticCurveTo(params[1], params[2], params[3], params[4]);
        break;
      case 'C':
        context.bezierCurveTo(params[1], params[2], params[3], params[4], params[5], params[6]);
        break;
      case 'A': {
        const arcParams = EllipseMath.getArcParams(currentPoint, params);
        const { cx, cy, rx, ry, startAngle, endAngle, xRotation, sweepFlag } = arcParams;
        // 直接使用椭圆的 api
        if (context.ellipse) {
          context.ellipse(cx, cy, rx, ry, xRotation, startAngle, endAngle, 1 - sweepFlag);
        } else {
          const r = rx > ry ? rx : ry;
          const scaleX = rx > ry ? 1 : rx / ry;
          const scaleY = rx > ry ? ry / rx : 1;
          context.translate(cx, cy);
          context.rotate(xRotation);
          context.scale(scaleX, scaleY);
          context.arc(0, 0, r, startAngle, endAngle, 1 - sweepFlag);
          context.scale(1 / scaleX, 1 / scaleY);
          context.rotate(-xRotation);
          context.translate(-cx, -cy);
        }
        break;
      }
      case 'Z':
        context.closePath();
        break;
      default:
        break;
    }

    // 有了 Z 后,当前节点从开始 M 的点开始
    if (command == 'Z') {
      currentPoint = startMovePoint;
    } else {
      const len = params.length;
      currentPoint = [params[len - 2], params[len - 1]];
    }
  }
}

注意点:

  • M  时需要开始的节点位置, 作为出现 Z 命令后的起点,否则同 SVG 的实现会有差异
  • 需要记录当前节点
  • 绘制圆弧时要转换生成椭圆的一些参数,判断是否支持椭圆绘制,如果布置则模拟实现

优化点

由于计算椭圆的参数比较耗时,反复绘制时每次都需要计算,所以可以缓存参数:

let arcParams;
// 为了加速绘制,可以提供参数的缓存,各个图形自己缓存
if (arcParamsCache) {
  arcParams = arcParamsCache[i];
  if (!arcParams) {
    arcParams = EllipseMath.getArcParams(currentPoint, params);
    arcParamsCache[i] = arcParams;
  }
} else {
  arcParams = EllipseMath.getArcParams(currentPoint, params);
}

 

拾取

Path 的拾取主要包含三个方面:

  • 包围盒的计算
  • 边的拾取
  • 内部填充的拾取

包围盒的计算

包围盒的计算是必须进行的,可以大大加快拾取速度,在前面的章节中介绍过包围盒的计算。本质上所有包围盒的计算都是找到一些极值点,然后计算其中x, y 的最大值、最小值,在这里我们队 Path 进行详细的讲解:

  • M,L 命令只需要将点放进包围盒计算的点即可
  • C,Q 的计算需要计算 C 和 Q 上的极值点和端点
  • A 需要计算一段圆弧上的极值点

这里主要介绍 C、Q、A 的极值计算,首先看一下他们的公式:

Quadratic Bezier:

(Endpoints: (x0, y0), (x2, y2);  Control Point: (x1, y1))

Equations:

Differentiating with respect to t: 

  •        
  •          

 

最终从 { , , } 中得到 x 的 min 和 max ,

从 { , , } 中得到 y 的 min 和 max ,

组成 BBox 的两个对角。

 

Cubic Bezier:

(Endpoints: (x0, y0), (x3, y3);  Control Point: (x1, y1), (x2, y2))

Equations:

Differentiating with respect to t: 

  •      
  •        

Rotated Ellipse

(center (h, k), semimajor axis A, semiminor axis B, rotate angle )

The parameterized equations for an rotated ellipse:

Differentiating with respect to t:

  •              
  •                      

 

所以对于 Q 命令的极值计算,使用公式:,代入 x,y 的公式既可以得到极值

function extrema(p0, p1, p2) {
    return (p0 - p1) / 3 * (p2 - p1);
}

 

对于 C 命令的极值计算,使用公式:,求解 t1, t2

function cubicExtrema(p0, p1, p2, p3) {
  const a = 3 * p0 - 9 * p1 + 9 * p2 - 3 * p3;
  const b = 6 * p1 - 12 * p2 + 6 * p3;
  const c = 3 * p2 - 3 * p3;
  const extrema = [];
  let t1;
  let t2;
  let discSqrt;

  if (Util.isNumberEqual(a, 0)) {
    if (!Util.isNumberEqual(b, 0)) {
      t1 = -c / b;
      if (t1 >= 0 && t1 <= 1) {
        extrema.push(t1);
      }
    }
  } else {
    const disc = b * b - 4 * a * c;
    if (Util.isNumberEqual(disc, 0)) {
      extrema.push(-b / (2 * a));
    } else if (disc > 0) {
      discSqrt = Math.sqrt(disc);
      t1 = (-b + discSqrt) / (2 * a);
      t2 = (-b - discSqrt) / (2 * a);
      if (t1 >= 0 && t1 <= 1) {
        extrema.push(t1);
      }
      if (t2 >= 0 && t2 <= 1) {
        extrema.push(t2);
      }
    }
  }
  return extrema;
}

 

A 的计算,需要分别求 x ,y 的极值点

 

function xExtrema(psi, rx, ry) {
  return Math.atan((-ry / rx) * Math.tan(psi));
}
function yExtrema(psi, rx, ry) {
  return Math.atan((ry / (rx * Math.tan(psi))));
}

 

边的拾取

Path 的边拾取需要逐段进行拾取,其中直线部分不做说明,我们的关注点仍然在 Q、C 和 A 上。

贝塞尔曲线 Q 和 C 如果想使用数学公式进行拾取会遇到一些麻烦,其思路是:

  • 计算点 (x,y) 到贝塞尔曲线的距离,如果距离小于边的宽度则确定在曲线上
  • 距离计算的公式: d = Math.sqrt((x-x0) * (x-x0)  + (y - y0) * (y - y0))

也就是对 (x-x0) * (x-x0)  + (y - y0) * (y - y0) 求导,导数为 0 处距离最短,我们分别将 Q 代表的曲线公式代入前面的距离公式可以得到:

对这个公式进行求导需要解 一元三次方程,而对 C 代表的曲线求导则需要求解一元五次方程,求解这两种方程在程序上很难实现,我们唯一的选择就是使用切割法,以 Q 命令为例:

function quadraticProjectPoint(x1, y1, x2, y2, x3, y3, x, y) {
  let t;
  let interval = 0.005;
  let d = Infinity;
  let d1;
  let v1;
  let v2;
  let _t;
  let d2;
  let i;
  const EPSILON = 0.0001;
  const v0 = [ x, y ];
    // 迭代求解距离最近的点
  for (_t = 0; _t < 1; _t += 0.05) {
    v1 = [
      quadraticAt(x1, x2, x3, _t),
      quadraticAt(y1, y2, y3, _t)
    ];

    d1 = vecDistance(v0, v1);
    if (d1 < d) {
      t = _t;
      d = d1;
    }
  }
  d = Infinity;
    // 反复进行迭代,达到距离 < EPSILON 的点
  for (i = 0; i < 32; i++) {
    if (interval < EPSILON) {
      break;
    }

    const prev = t - interval;
    const next = t + interval;

    v1 = [
      quadraticAt(x1, x2, x3, prev),
      quadraticAt(y1, y2, y3, prev)
    ];

    d1 = vecDistance(v0, v1);

    if (prev >= 0 && d1 < d) {
      t = prev;
      d = d1;
    } else {
      v2 = [
        quadraticAt(x1, x2, x3, next),
        quadraticAt(y1, y2, y3, next)
      ];

      d2 = vecDistance(v0, v2);

      if (next <= 1 && d2 < d) {
        t = next;
        d = d2;
      } else {
        interval *= 0.5;
      }
    }
  }

  const out = {
    x: quadraticAt(x1, x2, x3, t),
    y: quadraticAt(y1, y2, y3, t)
  }
  return out;
}

 

对 A 的拾取有三个方案:

  • 第一个方案:计算出当前点同 x 轴方向的夹角,通过椭圆的方程判定是否在边上即可
  • 第二个方案:将椭圆缩放成圆,对圆进行计算
  • 第三个方案:通过切割法计算点到椭圆的距离

 

第一个方案的伪码:

 

const arcParams = EllipseMath.getArcParams(currentPoint, params); // 包含椭圆的各种参数
const angle = getAngle(cx, cy, xRotation, x, y);
if (angle >= params.startAngle && angle <= params.endAngle) {
  const xSquare = (x - cx) * (x - cx);
  const ySquare = (y - cy) * (y - cy);
    return (
    ellipseDistance(xSquare, ySquare, rx - halfLineWith, ry - halfLineWith) >= 1 &&
    ellipseDistance(xSquare, ySquare, rx + halfLineWith, ry + halfLineWith) <= 1
  );
}
return false;

 

第二个伪码方案:

const p = [ x, y, 1 ];
const m = [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ];
const r = (rx > ry) ? rx : ry;
const scaleX = (rx > ry) ? 1 : rx / ry;
const scaleY = (rx > ry) ? ry / rx : 1;
mat3.translate(m, m, [ -cx, -cy ]);
mat3.rotate(m, m, -xRotation);
mat3.scale(m, m, [ 1 / scaleX, 1 / scaleY ]);
vec3.transformMat3(p, p, m);
return StrokeUtil.arc(cx, cy, r, startAngle, endAngle, lineWidth, p[0], p[1]);

 

填充的拾取

对填充的拾取,可以分为下面几种情况:

  • path 是一个 polygon(仅有一个 M,一个 Z) 或者 polyline (一个 M 无 Z),按照多边形进行拾取
  • path 是多个 polygon 和 polyline ,分别按照多个多边形进行拾取
  • 存在 A, C, Q
    • 一种方式:使用 isPointInPath 的方案
    • 另一种方式:判定是否在当前 A,C,Q 的包围盒内,如果在包围盒内则进行多边形划分,转换成 polygon 进行拾取

总结

Path 的渲染、拾取性能对整个 G 的影响非常大,所以这部分的优化不会停止。

posted @ 2021-04-23 18:18  方帅  阅读(280)  评论(0)    收藏  举报