图形编辑器开发:参考线吸附功能,让图形自动对齐

这篇具有很好参考价值的文章主要介绍了图形编辑器开发:参考线吸附功能,让图形自动对齐。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

最近我给图形编辑器增加了参照线吸附功能,讲讲我的实现思路。

我正在开发的图形设计工具:

https://github.com/F-star/suika

线上体验:

https://blog.fstars.wang/app/suika/

效果是被移动的图形会参考周围图形,自动与它们进行吸附对齐。

不得不说,很酷炫。

感觉这个图形编辑器突然变得灵动起来,有了灵魂一般。
图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

为什么需要参照线吸附功能?

这里的参照线,指的是在移动目标图形时,当靠近其他图形的包围盒的延长线(看不见)时,会(1)绘制出最近的延长线和延长线上的点,(2)并将目标图形吸附上去,轻松实现(3)对齐的效果。

图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

可以看到,通过参照线,我们很容易就能实现各种对齐,比如两图形的底边和定边对齐、右下角和左上角对齐。

这在 以对齐为基本要素 的视觉设计中,是非常好用的功能。

图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

整体思路

整体思路为:

  1. 记录参照线;
  2. 找出目标图形最靠近的水平参照线和垂直参照线;
  3. 计算出偏移值 offsetX、offsetY;
  4. 标记要绘制的所有参照线段(不是两端无限延长的);
  5. 修正图形的 x、y;
  6. 绘制参照线和点。

记录参照线

首先是确定能够作为 “参照” 的参照图形。

通常来说,参照图形为视口内的图形,并排除掉被移动的目标图形。视口外的图形通常都不在设计师的关注区域内。

确认好参照图形后,计算出它们的包围盒(bbox)。

这次的包围盒有点特殊,要多给一个中点坐标,因为中线也要作为参照线。

接口签名为:

export interface IBoxWithMid {
  minX: number;
  minY: number;
  midX: number;
  midY: number;
  maxX: number;
  maxY: number;
}

它们组成了参照图形的 8 个点,沿着这些点绘制竖线和横线,就是被移动的目标图形对应要吸附的参照线。

图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

被移动的图形也要计算包围盒,并得到 5 个点。基于这些点的产生的水平线和垂直线,在靠近参照线时会吸附到最近的参照线上,分为水平移动和垂直移动两个维度。

图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

编辑器上的效果:

图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

我们首先要把所有的参照线记录下来,在图形准备移动(mousedown)的时候。大致有以下这几个操作:

  1. 遍历参照图形(在视口内,且不为被移动目标图形);
  2. 计算出它们的包围盒,得到 8 个点,3 条垂直线和 3 条水平线。在一条垂直线上的多个点,其 x 值是相同的,y 不同,我们 x 作为 key,y 的数组为 value,保存到 hLineMap 映射对象中。每一项代表一条垂直线;
  3. 水平线同理,保存在 vLineMap 中。
  4. 然后对这两个 map 的 key 保存到 sortedXs 或 sortedYs 数组中,并排序,方便之后二分查找提高查找效率。

抽象一个 RefLine(参照线)类。

interface IVerticalLine { // 有多个端点的垂直线
  x: number;
  ys: number[];
}

interface IHorizontalLine { // 有多个端点的水平线
  y: number;
  xs: number[];
}


class RefLine {
  // 参照图形产生的垂直参照线,y 相同(作为 key),x 值不同(作为 value)
  private hLineMap = new Map<number, number[]>();
  // 参照图形产生的水平照线,x 相同(作为 key),y 值不同(作为 value)
  private vLineMap = new Map<number, number[]>(); 

  // 对 hLineMap 的 key 排序,方便高效二分查找,找到最近的线
  private sortedXs: number[] = []; 
  // 对 vLineMap 的 key 排序
  private sortedYs: number[] = []; 

  private toDrawVLines: IVerticalLine[] = []; // 等待绘制的垂直参照线
  private toDrawHLines: IHorizontalLine[] = []; // 等待绘制的水平参照线

  constructor(private editor: Editor) {}

  cacheXYToBbox() {
    this.clear();

    const hLineMap = this.hLineMap;
    const vLineMap = this.vLineMap;

    const selectIdSet = this.editor.selectedElements.getIdSet();
    const viewportBbox = this.editor.viewportManager.getBbox2();
    for (const graph of this.editor.sceneGraph.children) {
      // 排除掉被移动的图形
      if (selectIdSet.has(graph.id)) {
        continue;
      }

      const bbox = bboxToBboxWithMid(graph.getBBox2());
      // 排除在视口外的图形
      if (!isRectIntersect2(viewportBbox, bbox)) {
        continue;
      }
			
      // 将参照图形记录下来
   
      // 这里是水平线,特点是 x 相同。
      this.addBboxToMap(hLineMap, bbox.minX, [bbox.minY, bbox.maxY]);
      this.addBboxToMap(hLineMap, bbox.midX, [bbox.minY, bbox.maxY]);
      this.addBboxToMap(hLineMap, bbox.maxX, [bbox.minY, bbox.maxY]);

      this.addBboxToMap(vLineMap, bbox.minY, [bbox.minX, bbox.maxX]);
      this.addBboxToMap(vLineMap, bbox.midY, [bbox.minX, bbox.maxX]);
      this.addBboxToMap(vLineMap, bbox.maxY, [bbox.minX, bbox.maxX]);
    }

    this.sortedXs = Array.from(hLineMap.keys()).sort((a, b) => a - b);
    this.sortedYs = Array.from(vLineMap.keys()).sort((a, b) => a - b);
  }
  
  private addBboxToMap(
    m: Map<number, number[]>,
    xOrY: number,
    xsOrYs: number[],
  ) {
    const line = m.get(xOrY);
    if (line) {
      line.push(...xsOrYs);
    } else {
      m.set(xOrY, [...xsOrYs]);
    }
  }
  
  // ...
}

找出最近参照线

然后是找出目标图形最靠近的水平参照线和垂直参照线。

这一步是在图形移动(mousemove)时做的,是动态变化的。

首先我们分别找到目标图形的 minX、midX、maxX 的最近垂直参照线。

然后计算出它们各自的绝对距离。

最后找出这里面最小的一个。

class RefLinet {
  updateRefLine(_targetBbox: IBox2): {
    offsetX: number;
    offsetY: number;
  } {
    // 重置
    this.toDrawVLines = [];
    this.toDrawHLines = [];
    
    // 目标对象的包围盒,这里补上 midX,midY
    const targetBbox = bboxToBboxWithMid(_targetBbox);

    const hLineMap = this.hLineMap;
    const vLineMap = this.vLineMap;
    const sortedXs = this.sortedXs;
    const sortedYs = this.sortedYs;

    // 一个参照图形都没有,结束
    if (sortedXs.length === 0 && sortedYs.length === 0) {
      return { offsetX: 0, offsetY: 0 };
    }

    // 如果 offsetX 到最后还是 undefined,说明没有找到最靠近的垂直参照线
    let offsetX: number | undefined = undefined;
    let offsetY: number | undefined = undefined;

    // 分别找到目标图形的 minX、midX、maxX 的最近垂直参照线
    const closestMinX = getClosestValInSortedArr(sortedXs, targetBbox.minX);
    const closestMidX = getClosestValInSortedArr(sortedXs, targetBbox.midX);
    const closestMaxX = getClosestValInSortedArr(sortedXs, targetBbox.maxX);

    // 分别计算出距离
    const distMinX = Math.abs(closestMinX - targetBbox.minX);
    const distMidX = Math.abs(closestMidX - targetBbox.midX);
    const distMaxX = Math.abs(closestMaxX - targetBbox.maxX);

    // 找到最近距离
    const closestXDist = Math.min(distMinX, distMidX, distMaxX);
    
    // y 同理
  }
}

这里有一个比较重要的算法,就是找出排序数组中,离目标值最近的数组元素。

该算法二分查找的变体,虽然原理不复杂,但一次能写对,很难。这里我是找 gpt 帮我写的,非常完美。

实现如下:

const getClosestValInSortedArr = (
  sortedArr: number[],
  target: number,
) => {
  if (sortedArr.length === 0) {
    throw new Error('sortedArr can not be empty');
  }
  if (sortedArr.length === 1) {
    return sortedArr[0];
  }

  let left = 0;
  let right = sortedArr.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (sortedArr[mid] === target) {
      return sortedArr[mid];
    } else if (sortedArr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }

  // check if left or right is out of bound
  if (left >= sortedArr.length) {
    return sortedArr[right];
  }
  if (right < 0) {
    return sortedArr[left];
  }

  // check which one is closer
  return Math.abs(sortedArr[right] - target) <=
    Math.abs(sortedArr[left] - target)
    ? sortedArr[right]
    : sortedArr[left];
};

计算偏移值

前面我们得到了最小距离 closestXDist。

接着我们要判断其是否小于一个特定的临界值 tol。不可能你离着十米开外,移动一下就千里迢迢吸附过来了吧。

如果满足,我们继续。

offsetX 就差一步了,我们需要确定正负,因为 closestXDist 是一个绝对值。

那我们就拿这个最小距离和之前计算出的三个距离 distMinX、distMidX、distMaxX对比,找到相等的,就能计算出 offsetX 了。

const isEqualNum = (a: number, b: number) => Math.abs(a - b) < 0.00001;
    
const tol = 5 / zoom; // 最小距离不能超过这个

// 确认偏移值 offsetX
if (closestXDist <= tol) {
  // 这里考虑了一下浮点数误差
  if (isEqualNum(closestXDist, distMinX)) {
    offsetX = closestMinX - targetBbox.minX;
  } else if (isEqualNum(closestXDist, distMidX)) {
    offsetX = closestMidX - targetBbox.midX;
  } else if (isEqualNum(closestXDist, distMaxX)) {
    offsetX = closestMaxX - targetBbox.maxX;
  } else {
    throw new Error('it should not reach here, please put a issue to us');
  }
}

offsetY 同理,不赘述。

标记需绘制参照线段

计算出了 offsetX 和 offsetY。

接下来要修正一下我们的 targetBbox。

const correctedTargetBbox = { ...targetBbox };
if (offsetX !== undefined) {
  correctedTargetBbox.minX += offsetX;
  correctedTargetBbox.midX += offsetX;
  correctedTargetBbox.maxX += offsetX;
}
if (offsetY !== undefined) {
  correctedTargetBbox.minY += offsetY;
  correctedTargetBbox.midY += offsetY;
  correctedTargetBbox.maxY += offsetY;
}

修正后的目标图形,它的边就和一些参照线发生了对齐。

对齐的参照线,可能一条没有,可能只有一条,也可能有最多的 6 条

基于新的目标图形,我们来找它落在的参照线有哪些。

// offsetX 不为 undefined,说明落在了临界值内
if (offsetX !== undefined) {
  /*************** 左垂直的参考线 ************/
  // 对比 “offset” 和 “离 minX 最近的垂直线到 minX 的距离(不是绝对值)”
  if (isEqualNum(offsetX, closestMinX - targetBbox.minX)) {
    // 创建一个垂直线对象(特点是这些点的 x 相同)
    const vLine: IVerticalLine = {
      x: closestMinX,
      ys: [],
    };

    // 修正后的目标图形的对应点。
    vLine.ys.push(correctedTargetBbox.minY);
    vLine.ys.push(correctedTargetBbox.maxY);
    // 参照图形上的点
    vLine.ys.push(...hLineMap.get(closestMinX)!);

    // 添加到 “待绘制垂线集合”
    this.toDrawVLines.push(vLine);
  }
  /*************** 中间垂直的参考线 ************/
  if (isEqualNum(offsetX, closestMidX - targetBbox.midX)
  ) {
    const vLine: IVerticalLine = {
      x: closestMidX,
      ys: [],
    };

    vLine.ys.push(correctedTargetBbox.midY);
    vLine.ys.push(...hLineMap.get(closestMidX)!);

    this.toDrawVLines.push(vLine);
  }
  /*************** 右垂直的参考线 ************/
  // ...
}

// 水平线同理
if (offsetY !== undefined) {
  /*************** 上水平的参考线 ************/
  /*************** 中间水平的参考线 ************/
  /*************** 下水平的参考线 ************/
}

修正图形的 x、y

计算出的 offsetX 和 offsetY,记得拿去修正被移动目标图形的 x 和 y。

const onMousemove = (e) => {
  // ...

  const { offsetX, offsetY } = this.editor.refLine.updateRefLine(
    bboxToBbox2(this.editor.selectedElements.getBBox()!),
  );

  // 修正
  for (let i = 0, len = selectedElements.length; i < len; i++) {
    selectedElements[i].x = startPoints[i].x + dx + offsetX;
    selectedElements[i].y = startPoints[i].y + dy + offsetY;
  }
}

绘制参照线和点

最后是绘制参照线,以绘制垂直线为例。

for (const vLine of this.toDrawVLines) {
  let minY = Infinity;
  let maxY = -Infinity;

  // 这个是世界坐标系转视口坐标系
  const { x } = this.editor.sceneCoordsToViewport(vLine.x, 0);
  
  // 遍历绘制点
  for (const y_ of vLine.ys) {
    // TODO: optimize
    const { y } = this.editor.sceneCoordsToViewport(0, y_);
    minY = Math.min(minY, y);
    maxY = Math.max(maxY, y);

    // 可能有重复的点,用备忘录排除掉
    const key = `${x},${y}`;
    if (pointsSet.has(key)) {
      continue;
    }
    pointsSet.add(key);

    // 绘制点
    drawXShape(ctx, x, y, pointSize);
  }

  // 所有点中的 minY 和 maxY,绘制线段
  drawLine(ctx, x, minY, x, maxY);
}

图形编辑器开发:参考线吸附功能,让图形自动对齐,编辑器

水平线同理。

优化点

  1. 这里的实现,在图形有旋转角度的时候,参照线会过多显得冗余,可以精简一些,减少要对比的参照线;
  2. 对齐到像素网格的时候,包围盒的值要取整;
  3. 考虑和按住 Shift 固定 x 或 y 平移的情况。

最后

总结一下,参考线吸附的实现,就是找出最近的垂直线和水平线,计算出 offsetX 和 offsetY,修正被移动图形的 x 和 y,并记录并绘制出最终重合的参考线。

我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。文章来源地址https://www.toymoban.com/news/detail-549761.html

到了这里,关于图形编辑器开发:参考线吸附功能,让图形自动对齐的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 图形编辑器开发:是否要像 Figma 一样上 wasm

    大家好,我是前端西瓜哥。 wasm 拿来做 Web 端的图形编辑器貌似是不错的选择。 因为图形处理会有相当多无法利用到 WebGL GPU 加速的 CPU 密集的计算。比如对一条复杂贝塞尔曲线进行三角化,对多个图形进行复杂图形的布尔运算。 图形编辑器性能天花板 Figma 用了 wasm,我们也

    2024年02月15日
    浏览(39)
  • 图形编辑器开发:最基础但却复杂的选择工具

    大家好,我是前端西瓜哥。 对于一个图形设计软件,它最基础的工具是什么? 选择工具 。 但这个选择工具,却是相当的复杂。这次我来和各位,细说细说选择工具的一些弯弯道道。 我正在开发的图形设计工具的: https://github.com/F-star/suika 线上体验: https://blog.fstars.wang/ap

    2024年02月09日
    浏览(68)
  • web架构师编辑器内容-编辑器组件图层面板功能开发-锁定隐藏、键盘事件功能的开发

    我们这一部分主要是对最右侧图层面板功能进行剖析,完成对应的功能的开发: 每个图层都对应编辑器上面的元素,有多少个元素就对应多少个图层,主要的功能如下: 锁定功能:点击锁定,在编辑器中没法编辑对应的组件属性,再次点击是取消锁定,恢复到可编辑的模式

    2024年01月18日
    浏览(47)
  • web架构师编辑器内容-图层拖动排序功能的开发

    新的学习方法 用手写简单方法实现一个功能 然后用比较成熟的第三方解决方案 即能学习原理又能学习第三方库的使用 从两个DEMO开始 Vue Draggable Next: Vue Draggable Next React Sortable HOC: React Sortable HOC 列表排序的三个阶段 拖动开始(dragstart) 被拖动图层的状态变化 会出一个浮层

    2024年01月25日
    浏览(52)
  • 【Web开发指南】MyEclipse XML编辑器的高级功能简介

    MyEclipse v2023.1.2离线版下载 1. 在MyEclipse中编辑XML 本文档介绍MyEclipse XML编辑器中的一些可用的函数,MyEclipse XML编辑器包括高级XML编辑,例如: 语法高亮显示 标签和属性内容辅助 实时验证(当您输入时) 文档内容的源(Source)视图、设计(Design)视图和大纲(Outline)视图 文档

    2024年02月12日
    浏览(43)
  • 虚幻引擎架构自动化及蓝图编辑器高级开发进修班

    课程名称:虚幻引擎架构自动化及蓝图编辑器高级开发进修班 课程介绍 大家好 我们即将推出一套课程 自动化系统开发。 自动化技术在项目开发的前中后期都大量运用。如何您是一家游戏公司,做的是网络游戏,是不是经常会遇到程序员打包加部署需要半天时间,测试demo功

    2024年04月11日
    浏览(51)
  • 图形编辑器:历史记录设计

    大家好,我是前端西瓜哥。今天讲一下图形编辑器如何实现历史记录,做到撤销重做。 其实就是版本号的更替。每个版本保存一个状态。 要记录图形编辑器的历史记录,支持撤销重做功能,需要两个栈: 撤销(undo)栈和重做(redo)栈 。 每当用户进行一个操作(比如移动一

    2024年02月01日
    浏览(54)
  • 企升编辑器核心功能介绍

    核心功能介绍(企升编辑器)“排版助手”核心功能介绍(企升编辑器) 下载安装说明: 如何安装企升编辑器?“排版助手”如何安装企升编辑器? 一、支持目录生成选择标题级别 PB2023的目录操作有以下特点。一是清晰地区分出“默认目录内容”和“可选目录内容”,界面

    2024年02月11日
    浏览(33)
  • 电力布局三维编辑器功能设计

    最近和一家公司在谈一个项目合作,他们公司主要是做电力相关的。 项目背景大概是这样的: 国家电网对电网资产需要做到数字化管理,对现有变压器台区内的电表箱电能表做可视化数字孪生管理。 由于涉及到的台区非常多,所以客户希望开发的不是单个项目,而是可以实现

    2023年04月22日
    浏览(43)
  • 免费全功能视频编辑器分享

    最基本的视频剪辑功能,文字、音频、画中画、变速、滤镜、贴纸、AI字幕,各种字体、特效都可以免费使用 还有许多实用功能,AI作图,可以通过输入咒语,来生成一些图片,作为视频配图使用 背景移除,可以一键移除背景,保留主体 。内置了提词器功能。安卓电脑都可以

    2024年01月22日
    浏览(53)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包