图形编辑器

2 阅读9分钟

本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。

作者: 百应前端团队 青畅

前言

图形编辑器提供了一个交互式的界面,允许用户在网页中创建、编辑和操作各种图形元素。 编辑器是比较复杂的项目,由多个功能模块组合而成。一个优秀的编辑器,其必定需要考虑一些特点:模块化、可扩展性、分层架构、设计模式、性能优化。

编辑器开发比较侧重的一点是如何交互,基本上是围绕鼠标事件进行交互。

其中也会涉及计算图形学,比如碰撞检测算法、贝塞尔曲线算法、Bresenham 算法、树形布局算法等,有些复杂的图形学知识,可能需要借助于第三方库。

基础模块

1. 画布模块

画布模块是整个图形编辑器的核心,提供了一个可视化区域,用于显示和操作图形元素。它负责管理图形对象的渲染、交互和布局。画布模块可以处理用户的输入事件,如鼠标点击、移动、缩放等,以及绘制、移动、删除图形元素等操作。

2. 坐标模块

坐标模块负责转换和管理画布上的坐标系统。它将屏幕坐标映射到画布坐标,并提供方法来获取、转换和计算不同坐标系之间的关系。坐标模块还可以支持各种坐标变换,例如平移、缩放和旋转等,以便实现图形元素的准确定位和变换。

3. 工具栏模块

工具栏模块通常位于编辑器界面的一部分,提供了各种工具和选项,用于选择、创建、修改图形元素的属性和样式。它可以包含按钮、下拉菜单、滑块等控件,供用户选择和调整不同的绘图工具、颜色、线型等。

4. 历史记录模块

历史记录模块用于跟踪用户的操作,并提供撤销和重做功能。它记录了编辑器中的每个操作(如创建、移动、修改图形元素)以及相应的参数和状态变化。通过历史记录模块,用户可以回溯到之前的状态,撤销一系列操作或重新执行已撤销的操作。

常用算法

碰撞检测(Collision Detection)

碰撞检测算法用于检测图形元素之间是否发生碰撞或重叠。对于简单的几何形状,如矩形、圆形,可以使用基于边界框的碰撞检测算法。对于复杂的图形,如多边形、曲线等,可能需要更复杂的算法,如分离轴定理像素检测等。

1. 矩形与矩形检测

var rect1 = {x: 5, y: 5, width: 50, height: 50}
var rect2 = {x: 20, y: 10, width: 10, height: 10}

if (rect1.x <= rect2.x + rect2.width &&
   rect1.x + rect1.width >= rect2.x &&
   rect1.y <= rect2.y + rect2.height &&
   rect1.height + rect1.y >= rect2.y) {
   // collision detected!
}

2. 圆形与圆形碰撞

var circle1 = {radius: 20, x: 5, y: 5};
var circle2 = {radius: 12, x: 10, y: 5};

var dx = circle1.x - circle2.x;
var dy = circle1.y - circle2.y;
var distance = Math.sqrt(dx * dx + dy * dy);

if (distance <= circle1.radius + circle2.radius) {
    // collision detected!
}

3. 圆形与矩形碰撞

var circle1 = {r: 20, x: 5, y: 5};
var rect1 = {x: 5, y: 5, w: 50, h: 50}

function detectCollision(rect, circle) {
    // 矩形上距离圆心最近的点
    var cx, cy

    if(circle.x < rect.x) {
            cx = rect.x
    } else if(circle.x > rect.x + rect.w) {
            cx = rect.x + rect.w
    } else {
            cx = circle.x
    }

    if(circle.y < rect.y) {
            cy = rect.y
    } else if(circle.y > rect.y + rect.h) {
            cy = rect.y + rect.h
    } else {
            cy = circle.y
    }

    if(distance(circle.x, circle.y, cx, cy) < circle.r) {
            return true
    }

    return false
}

function distance(x1, y1, x2, y2) {
        return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
}

4. 分离轴定理(Separating Axis Theorem,简称 SAT)

概念:通过判断任意两个凸多边形在任意角度下的投影是否均存在重叠,来判断是否发生碰撞。若在某一角度光源下,两物体的投影存在间隙,则为不碰撞,否则为发生碰撞。

5. 像素检测

像素碰撞检测是一种用于检测两个对象是否发生碰撞的技术。在计算机图形和游戏开发中,它通常用于确定两个图像是否重叠或接触。

每个对象都由一个像素矩阵组成,其中每个像素代表一个图像的单个点。像素碰撞检测通过检查这些像素的位置和颜色值来确定对象之间是否存在交集。

在执行像素碰撞检测时,我们遍历两个对象的像素,以逐个比较它们的位置和颜色。如果在相同位置上的像素具有相似的颜色值,则可以认为发生了碰撞。

// 简化的像素碰撞检测函数
function pixelCollision(sprite1, sprite2) {
  const rect1 = sprite1.getBoundingClientRect();
  const rect2 = sprite2.getBoundingClientRect();

  // 检查两个对象的边界框是否重叠
  if (
    rect1.left < rect2.right &&
    rect1.right > rect2.left &&
    rect1.top < rect2.bottom &&
    rect1.bottom > rect2.top
  ) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    // 绘制两个对象的图像到画布上
    ctx.drawImage(sprite1, 0, 0);
    ctx.globalCompositeOperation = 'source-in';
    ctx.drawImage(sprite2, 0, 0);

    // 获取画布上重叠区域的像素数据
    const overlapImageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;

    // 检查重叠区域是否存在非透明像素
    for (let i = 0; i < overlapImageData.length; i += 4) {
      if (overlapImageData[i + 3] !== 0) {
        return true; // 发生了像素碰撞
      }
    }
  }

  return false; // 没有发生像素碰撞
}

// 使用示例
const sprite1 = document.getElementById('sprite1');
const sprite2 = document.getElementById('sprite2');

// 在游戏循环中进行像素碰撞检测
function gameLoop() {
  const isCollision = pixelCollision(sprite1, sprite2);

  if (isCollision) {
    // 处理碰撞逻辑
    console.log('发生了像素碰撞!');
  }

  // 继续进行下一帧的游戏循环
  requestAnimationFrame(gameLoop);
}

// 启动游戏循环
gameLoop();

贝塞尔曲线(Bézier curve)

贝塞尔曲线算法用于创建和绘制平滑的曲线。它通过控制点来定义曲线的形状,主要有二次贝塞尔曲线和三次贝塞尔曲线两种常见类型。贝塞尔曲线算法可以使用递归或迭代方式实现,根据给定的控制点计算出曲线上的坐标点,从而实现平滑的曲线绘制。

特性:

贝塞尔曲线需要提供几个点的参数,首先是曲线的起点和终点,然后再提供任意数量的控制点。

如果控制点数量为 0,我们称之为线性贝塞尔;

控制点数量为 1,则为二次贝塞尔曲线;

控制点数量为 2,则为三次贝塞尔曲线,依此类推。

公式

线性贝塞尔

二次贝塞尔

DF: DE = AD: AB = BE: BC

三次贝塞尔

AE: AB = BF: BC = CG: CD = EH: EF = FI: FG = HJ: HI

高次贝塞尔

由此,n 阶贝塞尔曲线可如下推断。给定点 P0、P1、…、Pn,其贝塞尔曲线即:

SVG 绘制贝塞尔

路径 - SVG:可缩放矢量图形 | MDN

指令作用使用方法
M移动到指定坐标位置,将当前点设置为起始点M x0,y0
L绘制一条直线到指定坐标位置L x1,y1
H水平绘制一条直线到指定的 x 坐标位置H x2
V垂直绘制一条直线到指定的 y 坐标位置V y2
C绘制一条贝塞尔曲线,需要两个控制点C x1,y1 x2,y2 x3,y3
S绘制一条光滑的贝塞尔曲线,需要两个控制点S x2,y2 x3,y3
Q绘制一条二次贝塞尔曲线,需要一个控制点Q x1,y1 x2,y2
T绘制一条光滑的二次贝塞尔曲线,需要一个控制点T x2,y2
A绘制一段椭圆弧线A rx,ry x-axis-rotation large-arc-flag sweep-flag x3,y3
Z关闭当前路径Z

<path
  stroke="transparent"
  className="path-1"
  d="M 0 15 A 32 32 0 0 0 32,47 L 32 15 Z"
  fill="#7debb8"
/>
<path
  stroke="transparent"
  className="path-2"
  d="M 32,47, A 32 32, 0, 0, 0, 64, 15 L 32 15 Z"
  fill="#ee967a"
/>

Q 二次贝塞尔曲线

用法:M x0,y0 Q x1,y1 x2,y2

T 二次贝塞尔曲线平滑延伸

用法:M x0,y0 Q x1,y1 x2,y2 T x4,y4

C 三次贝塞尔曲线

用法:M x0,y0 C x1,y1 x2,y2 x3,y3

S 三次贝塞尔曲线平滑延伸

用法:M x0,y0 C x1,y1 x2,y2 x3,y3 S x5,y5 x6,y6

Canvas 绘制贝塞尔

import { path } from 'd3-path';
/** 计算连接线路径 */
export const computeLinePath = (start: IPosition, end: IPosition)/* istanbul ignore next */ => {
    const svgPath = path();
    const distance = Math.abs(start.x - end.x) * 0.8;
    svgPath.moveTo(start.x, start.y);
    svgPath.bezierCurveTo(start.x + distance, start.y, end.x - distance, end.y, end.x, end.y);
    return svgPath.toString();
};

变换算法(Transformation Algorithm)

矩阵变换就是一种坐标系的转换

常见的矩阵变换包括平移矩阵(translation matrix)、旋转矩阵(rotation matrix)、缩放矩阵(scale matrix)和斜切矩阵(shear matrix)等

缩放矩阵

平移矩阵

旋转矩阵

齐次坐标

齐次坐标是一种在计算机图形学中常用的表示方法,用于描述二维或三维空间中的点、向量和变换。它通过增加一个维度来扩展传统的笛卡尔坐标系,从而提供更灵活和方便的数学运算和表示方法。

引入齐次坐标的主要原因有以下几点:

  1. 方便表示无穷远点:在传统的笛卡尔坐标系中,无法直接表示无穷远处的点。但在齐次坐标系统中,可以将无穷远点表示为具有坐标 (x, y, 0, 0) 的向量,并进行相应的数学运算。

  2. 统一表示点和向量:齐次坐标可以统一表示点和向量,使它们在数学运算上更加一致。点可以看作是一个特殊的向量,通过设置额外的齐次分量为非零值(通常为 1)来区分点和向量。

  3. 简化平移操作:在传统的坐标系统中,平移操作需要通过增加或减少坐标值来实现。而在齐次坐标系统中,平移操作可以通过矩阵乘法来实现,简化了平移的表示和计算。

  4. 支持多个变换的组合:齐次坐标可以方便地表示多个变换的组合,如平移、旋转和缩放。通过将这些变换矩阵相乘,可以得到一个综合的变换矩阵,从而实现复杂变换的应用。

举一个缩放的例子🌰

已知一个点 Point(x,y),缩放中心点 Center(x,y),缩放比:scale,计算其缩放之后的坐标 Scale(x,y)

const point = { x: 10, y: 10 };
const centerX = 1;
const centerY = 1;
const scaleX = 0.5;
const scaleY = 0.5;

// 应用平移矩阵将中心点平移到原点
const translatedX = point.x - centerX;
const translatedY = point.y - centerY;

// 应用缩放矩阵进行缩放
const scaledX = translatedX * scaleX;
const scaledY = translatedY * scaleY;

// 再次应用平移矩阵恢复中心点位置
const finalX = scaledX + centerX = (point.x - centerX) * scaleX + centerX;
const finalY = scaledY + centerY = (point.y - centerY) * scaleY + centerY;

反之,在画布缩放的应用场景下,我们已知缩放之后的坐标 R(x,y),想要计算其原坐标为

R.x = (point.x - centerX) * scaleX + centerX ; point.x = (R.x - centerX)/scaleX + centerX

R.y = (point.y - centerY) * scaleY + centerY; point.y = (R.y - centerY)/scaleY + centerY

export const computeCanvasPo = (position: IPosition, $wrapper: HTMLDivElement) => {
    const {
        centerX, centerY, rect, zoom, scrollLeft, scrollTop
    } = computeCanvasPoHelper($wrapper);
    const po = {
        x: centerX + (position.x - centerX) / zoom + (scrollLeft + window.pageXOffset - rect.left) / zoom,
        y: centerY + (position.y - centerY) / zoom + (scrollTop + window.pageYOffset - rect.top) / zoom,
    } as IPosition;
    return po;
};

资料收集

developer.mozilla.org/zh-CN/docs/…

github.com/phenomLi/Bl…

www.zhihu.com/question/29…

zh.javascript.info/bezier-curv…