图形编辑器开发:缩放和旋转控制点

大家好,我是前端西瓜哥。好久没写图形编辑器开发的文章了。

今天来讲讲控制点。它是图形编辑器的不可缺少的基础功能。

控制点是吸附在图形上的一些小矩形和圆形点击区域,在控制点上拖拽鼠标,能够实时对被选中进行属性的更新。

比如使用旋转控制点可以更新图形的旋转角度,使用缩放控制点调整图形的宽高。

这两个都是通用的控制点,此外还有给特定图形使用的专有控制点,像是矩形的圆角控制点,可拖动调整圆角大小。这些比较特别。后面会专门出一篇文章讲这个。

需求描述

选中图形,会出现旋转控制点和缩放控制点,然后操作控制点。

关于控制点的位置,示意图如下。

缩放控制点有 8 个。

首先是 西北(nw)、东北(ne)、东南(se)、西南(sw)缩放控制点。它们在选中图形的四个角鹿,可以同时更新图形的宽高。

接着是 东(e)、南(s)、西(w)、北(n)缩放控制点,拖拽它们只更新图形的宽或高。

它们是不可见的,但在 hover 上去光标会变成缩放的光标。这类控制点的点击区域见下图。

旋转控制点有 4 个,对应四个角落,分别为:nwRotation、neRotation、seRotation、swRotation

同样它们是透明的,但 hover 上去光标会变成旋转光标。

旋转控制点有另外一种风格,就是只在图形的某个方向(通常是正上方)有一个可见旋转控制点。下面是 Canva 编辑器的效果:

我更喜欢第一种风格,画面会更清爽一些。

实现思路

整体实现思路很简单:

  1. 根据图形的包围盒,计算这些控制点的位置,设置好宽高;
  2. 渲染,设置为不可见的控制点跳过渲染;
  3. hover 或点击时,编辑器会做 图形拾取,会和渲染顺序相反的顺序遍历控制点,调用控制点图形的 hitTest 方法找到第一个被点中的图形,返回对应控制点的类型和光标。然后编辑器更新光标,并根据控制点类型进入对应逻辑。如果你是用 html/svg 的方案,图形拾取可以不用自己做。

代码设计

我们抽象一个控制点管理类 ControlHandleManager 和控制点类 ControlHandle。

ControlHandle 类记录以下信息:

  1. graph:图形对象,记录控制点的左上角位置、宽高、颜色、是否可见,并带了一个点击区域方法;
  2. cx / cy:控制点的中点位置;
  3. getCursor():获取光标方法,hover 时返回一个需要设置的光标值。

这里直接用图形编辑器绘制图形用到的图形类。

通常你使用的渲染图形库是会有

创建 ControlHandle 对象。

我们需要创建的控制点对象为:

// 右下角(ns)的控制点  
const se = new ControlHandle({
  graph: new Rect({
    objectName: 'se', // 控制点类型标识,放其他地方也行
    cx: 0, // x 和 y 会根据选中图形的包围盒更新
    cy: 0,
    width: 6,
    height: 6,
    fill: 'white',
    stroke: 'blue',
    strokeWidth: 1,
  }),
  getCursor: (type, rotation) => {
    // ...
    return 'se-rezise'
  } ,
});

这个对象会保存到控制点管理类的 transformHandles 属性中。

transformHandles 是一个映射表,类型标识字符串映射到控制点对象。

class ControlHandleManager {
  visible = false;
  transformHandles;

  constructor() {
    // 映射表 type -> 控制点
    this.transformHandles = {
      se: new ControlHandle(/* ... */),
      n: new ControlHandle(/* ... */),
      nwRoation: new ControlHandle(/* ... */),
      // ...
    }
  }
}

渲染

当我们选中图形时,调用渲染方法。

此时会调用 ControlHandleManager 的 draw 渲染方法,渲染控制点。

  1. 根据包围盒计算控制点的中点位置。这个包围盒有 x、y、width、height、rotation 属性。我们需要计算这个包围盒的四个顶点的位置,包围盒外扩一定距离后的四个顶点的位置,四条线段的中点的位置。
class ControlHandleManager {
  // ...
  
  /** 渲染控制点 */
  draw(rect: IRectWithRotation) {
  
  // calculate handle position
  const handlePoints = (() => {
    const cornerPoints = rectToPoints(rect);
    const cornerRotation = rectToPoints(offsetRect(rect, size / 2 / zoom));
    const midPoints = rectToMidPoints(rect);

    return {
      ...cornerPoints,
      ...midPoints,
      nwRotation: { ...cornerRotation.nw },
      neRotation: { ...cornerRotation.ne },
      seRotation: { ...cornerRotation.se },
      swRotation: { ...cornerRotation.sw },
    };
  })();
 }
}
  1. 遍历控制点对象,赋值上对应的中点坐标:cx、cy。调整 n/s/w/e 的宽高,它们的宽高是跟随
// 整个顺序是有意义的,是渲染顺序
const types = [
  'n',
  'e',
  's',
  'w',
  'nwRotation',
  'neRotation',
  'seRotation',
  'swRotation',
  'nw',
  'ne',
  'se',
  'sw',
] as const;

// 更新 cx 和 cy
for (const type of types) {
  const point = handlePoints[type];
  const handle = this.transformHandles.get(type);
  handle.cx = point.x;
  handle.cy = point.y;
}

// n/s/w/e 比较特殊,n/s 的宽和包围盒宽度相等,w/e 高等于包围盒高。
const neswHandleWidth = 9;
const n = this.transformHandles.get('n')!;
const s = this.transformHandles.get('s')!;
const w = this.transformHandles.get('w')!;
const e = this.transformHandles.get('e')!;
n.graph.width = s.graph.width = rect.width * zoom;
n.graph.height = s.graph.height = neswHandleWidth;
w.graph.height = e.graph.height = rect.height * zoom;
w.graph.width = e.graph.width = neswHandleWidth;
  1. 接着就是遍历 transformHandles,基于 cx 和 cy 更新图形的 x/y,然后绘制。
this.transformHandles.forEach((handle) => {
  // 场景坐标转视口坐标
  const { x, y } = this.editor.sceneCoordsToViewport(handle.cx, handle.cy);
  const graph = handle.graph;
  graph.x = x - graph.width / 2;
  graph.y = y - graph.height / 2;
  graph.rotation = rect.rotation;

  // 不可见的图形不渲染(本地调试的时候可以让它可见)
  if (!graph.getVisible()) {
    return;
  }

  graph.draw();
});

渲染逻辑到此结束。

控制点拾取

然后就是在选择工具下,hover 到控制点上,对光标进行设置。并且在按下鼠标时,能够拿到对应的控制点类型,进行对应的旋转或缩放操作。

控制点拾取逻辑为:

以渲染顺序相反的方向遍历控制点,调用 hitTest 方法检测光标是否在控制点的点击区域上。

如果在,返回 type 和 cursor;否则返回 null。

class ControlHandleManager {
  // ...

  /** 获取在光标位置的控制点的信息 */
  getHandleInfoByPoint(hitPoint: IPoint) {
    const hitPointVW = this.editor.sceneCoordsToViewport(
      hitPoint.x,
      hitPoint.y,
    );
    
    for (let i = types.length - 1; i >= 0; i--) {
      const type = types[i];
      const handle = this.transformHandles.get(type);
 
      // 是否点中当前控制点
      const isHit = handle.graph.hitTest(
        hitPointVW.x,
        hitPointVW.y,
        handleHitToleration,
      );

      if (isHit) {
        return {
          handleName: type, // 控制点类型
          cursor: handle.getCursor(type, rotation), // 光标
        };
      }
    }
  }  
}

反向很重要,应为可能会有控制点发生重叠,此时应该是在更上方的控制点,也就是后渲染的控制点优先被选中。

光标

getCursor 返回的光标值是动态的,会因为包围盒的角度不同而变化,这里会有一个简单的转换。

const getResizeCursor = (type: string, rotation: number): ICursor => {
  let dDegree = 0;
  switch (type) {
    case 'se':
    case 'nw':
      dDegree = -45;
      break;
    case 'ne':
    case 'sw':
      dDegree = 45;
      break;
    case 'n':
    case 's':
      dDegree = 0;
      break;
    case 'e':
    case 'w':
      dDegree = 90;
      break;
    default:
      console.warn('unknown type', type);
  }

  const degree = rad2Deg(rotation) + dDegree;
  // 这个 degree 精度是很高的,
  // 设置光标时会做一个舍入,匹配一个合法的接近光标值,比如 ne-resize
  return { type: 'resize', degree };
}

旋转光标同理。

此外,浏览器支持的 resize 光标值是有限的。

为了更好的效果是实现 resize0 ~ resize179 代表不同角度的一共 180 个自定义 resize 光标。

或者做一个 “四舍五入”,转为浏览器支持的那几种 resize 角度,但这样光标效果不是很好,看起来光标并没有和控制点垂直,算是一种妥协。

旋转光标更是不存在了,我们要设计 rotation0 ~ rotation179 共 360 个自定义光标。当然我们可以让精度降一下,比如只实现偶数值的旋转角度的光标,比如 rotation0、rotation2、rotation4,也要 180 个。

关于自定义光标的实现方案,本文不深入讲解,会单独写一篇文章讨论。

坐标系

有个容易忽略的问题,就是控制点是绘制在哪个坐标系中的?

是场景坐标系,还是视口坐标系。

如果在场景坐标系中,图形会随画布的缩放或移动 “放大缩小”,比如一根 2px 的线条,在 zoom 为 50% 的画布下,显示的效果是 1px。

控制点的宽高是不应该跟随 zoom 而变化的。

如果你绘制在视口坐标系,宽高不需要考虑,只要转换一下 x,y。如果在场景坐标中,x、y 不用转换,但是宽高要除以 zoom。

缩放和旋转图形

如何缩放和旋转图形就超出本文的话题范围了,但如果你感兴趣的话,可以看我的这几篇文章:

《图形编辑器开发:实现缩放图形》

《图形编辑器:旋转选中的元素》

结尾

我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/188180.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

LED面板显示屏驱动芯片

一、基本概述 TM1638是一种带键盘扫描接口的LED(发光二极管显示器)驱动控制专用IC,内部集成有MCU数字接口、数据锁存器、LED驱动、键盘扫描等电路。本产品质量可靠、稳定性好、抗干扰能力强。 二、主要应用场合 主要适用于家电设备(智能热水器、微波炉…

Hibernate 脏检查和刷新缓存机制

刷新缓存: Session是Hibernate向应用程序提供的操作数据库的主要接口,它提供了基本的保存,更新,删除和加载java对象的方法,Session具有一个缓存,可以管理和追踪所有持久化对象,对象和数据库中的相关记录对应,在某些时间点,Session会根据缓存中对象的变化来执行相关SQL语句,将对…

杂货铺 | Windows系统上解压缩tgz文件

文章目录 📚快速终端打开实现 & 解压缩实现步骤📚环境变量的一般配置步骤 & 问题解决思路 📚快速终端打开实现 & 解压缩实现步骤 将对应的tgz文件放入对应的文件夹。快速在指定文件夹下打开终端 打开对应的路径 双击地址栏 然后…

Cisco Packet Tracer配置命令——路由器篇

路由基础 路由器用于互联两个或多个网络,具有两项功能:为要转发的数据包选择最佳路径以及将数据包交换到正确的端口,概括为路由选择和分组转发。 路由选择 路由选择就是路由器根据目的IP地址的网络地址部分,通过路由选择算法确…

图的建立基本操作

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> // 添加头文件#define MAX_VERTEX_NUM 100 //图中最大顶点数//struct ArcNode* nextarc; //ArcNode* firstarc; //这两个是很有必要的&#xff0c;如果你没有这两个指针&#xff0c;你就无法判…

力扣114. 二叉树展开为链表(java,用树模拟链表)

Problem: 114. 二叉树展开为链表 文章目录 题目描述思路解题方法复杂度Code 题目描述 给你二叉树的根结点 root &#xff0c;请你将它展开为一个单链表&#xff1a; 1.展开后的单链表应该同样使用 TreeNode &#xff0c;其中 right 子指针指向链表中下一个结点&#xff0c;而左…

Selenium 4.11 正式发布--再也不用手动更新chrome driver 了

Selenium 4.11.0 正式发布了&#xff0c;先来看一下主要特性。 Chrome DevTools支持的版本现在是&#xff1a;v113、v114和v115&#xff08;Firefox仍然对所有版本使用v85&#xff09; 通过Selenium Manager支持Chrome For Testing&#xff08;CfT&#xff09; Selenium Manag…

LeetCode Hot100 105.从前序与中序遍历序列构造二叉树

题目&#xff1a;给定两个整数数组 preorder 和 inorder &#xff0c;其中 preorder 是二叉树的先序遍历&#xff0c; inorder 是同一棵树的中序遍历&#xff0c;请构造二叉树并返回其根节点。 代码&#xff1a; class Solution {private Map<Integer, Integer> indexM…

midjourney过时了?如何使用基于LCM的绘图技术画出你心中的画卷。

生成 AI 艺术在近年来迅速发展&#xff0c;吸引了数百万用户。然而&#xff0c;传统的生成 AI 艺术需要等待几秒钟或几分钟才能生成&#xff0c;这对于快节奏的现代社会来说并不理想。 近日&#xff0c;中国清华大学和 AI 代码共享平台 HuggingFace 联合开发了一项新的机器学习…

性能压测工具:wrk

一般我们压测的时候&#xff0c;需要了解衡量系统性能的一些参数指标&#xff0c;比如。 1、性能指标简介 1.1 延迟 简单易懂。green:一般指响应时间 95线&#xff1a;P95。平均100%的请求中95%已经响应的时间 99线&#xff1a;P99。平均100%的请求中99%已经响应的时间 平…

2023年亚太杯数学建模A题水果采摘机器人的图像识别功能(matlab 部分代码)

对于1-4问针对的是附录1 中的数据 clc; close all; clear; % 图像文件夹路径 folder_path E:/新建文件夹/yatai/Attachment/Attachment 1/; % 图像文件列表 image_files dir(fullfile(folder_path, *.jpg)); % 假设所有图片都是jpg格式% 解析文件名中的数字&#xff0c;并转…

2023年汉字小达人市级比赛在线模拟题的使用顺序、建议和常见问题

今天是2023年11月25日&#xff0c;星期六&#xff0c;上午举办了2023年第八届上海小学生古诗文大会的复选活动&#xff08;复赛&#xff09;&#xff0c;结束了复选活动&#xff0c;很多学霸孩子们马上就开始投入到第十届汉字小达人的市级活动&#xff08;市级比赛&#xff09;…

STM32 配置中断常用库函数

单片机学习 目录 一、配置AFIO相关库函数 1.1函数GPIO_AFIODeInit 1.2函数GPIO_EventOutputConfig 1.3函数GPIO_EventOutputCmd 1.4函数GPIO_EXTILineConfig 二、配置EXTI相关库函数 2.1函数EXTI_DeInit 2.2函数EXTI_Init 2.3函数EXTI_StructInit 2.4函数 EXTI_Gener…

python_接口自动化测试框架

&#x1f4e2;专注于分享软件测试干货内容&#xff0c;欢迎点赞 &#x1f44d; 收藏 ⭐留言 &#x1f4dd; 如有错误敬请指正&#xff01;&#x1f4e2;交流讨论&#xff1a;欢迎加入我们一起学习&#xff01;&#x1f4e2;资源分享&#xff1a;耗时200小时精选的「软件测试」资…

02_MySQL体系结构及数据文件介绍

#课程目标 了解MySQL的体系结构了解MySQL常见的日志文件及作用了解事务的控制语句&#xff0c;提交和回滚能够查看当前数据库的版本和用户了解MySQL数据库如何存放数据能在使用SQL语句创建、删除数据库 #一、MySQL的体系结构 ##1、客户端(连接者) MySQL的客户端可以是某个客户…

vue2-006——使用脚手架搭建vue2项目+项目结构分析

一、创建项目&#xff1a;vue create 项目名 D:\EnyiWang\Documents\myStudy\vue>vue create vue_testVue CLI v5.0.8 ? Please pick a preset: Default ([Vue 2] babel, eslint)Vue CLI v5.0.8 ✨ Creating project in D:\EnyiWang\Documents\myStudy\vue\vue_test. &am…

Java基准测试工具JMH的简介与使用

JMH是一套Java基准测试工具&#xff0c;用于对Java执行进行基准测试以及生成测试报告。平时应用于Java一些基础Api或者一些工具类这种离开网络因素的纯系统测试。 使用方式 maven引入&#xff1a; <dependency><groupId>org.openjdk.jmh</groupId><art…

postman和Jmeter做接口测试的区别(经验之谈)

接口测试的目的 API 测试作为集成测试的一部分&#xff0c;经过被测应用的接口&#xff08;API&#xff09;来确定是否在功能、可靠性、性能和安全方面达到预期的软件测试。因为 API 都没有 GUI 界面&#xff0c;API 测试都是在通信层进行的。 1.建立接口用例集 Postman功能…

QT visual stdio加载动态库报错126问题

报错126是找不到指定的模块 QT 查看构建目录&#xff0c;将依赖的动态库放到该目录下即可成功 visual stdio将依赖的动态库放到运行目录 在vs中使用导出类的动态库时&#xff0c;不但需要将对应的.dll放到对应的目录下&#xff0c;还需要将该动态库对应的.lib添加到如下配置才…

AJAX技术-04-- 跨域说明

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 1 同源策略同源策略介绍规定要求 请求协议://域名:端口号 关于同源策略练习关于同源策略总结 2.JSONPJSONP原理说明关于JSONP优化 3.CORS介绍介绍不允许跨域说明跨域…