前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >图形编辑器开发:基于 transfrom 的图形缩放

图形编辑器开发:基于 transfrom 的图形缩放

作者头像
前端西瓜哥
发布2024-04-28 10:11:29
980
发布2024-04-28 10:11:29
举报

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

上一篇文章我们讲了为什么以及如何用 transform、width 和 height 表达图形。

这篇文章我们来看看基于 transform 的这种表达形式,要如何实现图形的缩放(resize)。

transform 变形

有一个图形。

它没做变形前是这样的,或者说如果矩阵是单位矩阵,它是这样的。

x 和 y 是 0。宽和高分别为 width、height。

注意我们不会使用 x 和 y 属性,我们会用矩阵的 tx 和 ty 表达位置,所以它们固定为原点位置。

然后我们给它应用一个 transform 矩阵,得到形变效果。

比如下面是先旋转 delta 度,然后移动 tx、ty 位置的效果。

缩放控制点

矩阵的作用是,给点做一个线性变换使其映射到新的位置。

对于图形,其实就是将原来图形上的每一个点做了重映射,然后得到图形的缩放、旋转、位移等效果。

我们基于矩形的 4 个顶点,

  1. nw(左上):(0, 0)
  2. ne(右上):(width, 0)
  3. se(右下):(width, height)
  4. sw(左下):(0, height)

做矩阵变换,得到 4 个缩放控制点。

我们将它们渲染出来,需要支持 通过拖拽它们,实现以对角为缩放中心,改变图形的尺寸

resize 操作会更新三个属性:width、height 和 transform。

我们来看看算法实现。

算法实现

缩放的总体思路为:

  1. 光标的视口坐标,转为场景坐标。(viewportPt -> globalPt);
  2. 光标场景坐标,转换为图形的本地坐标。(globalPt -> localPt);
  3. 计算 localPt 到缩放中心点的垂直和水平方向差值的 绝对值,作为新的 width 和 height;
  4. 计算 localPt 相对缩放中心点,是否发生水平翻转,生成一个 缩放值的绝对值为 1 的缩放矩阵
  5. 为保证缩放前后缩放中心点位置不变,计算它在缩放前后场景坐标系下的偏移,得到一个位移矩阵
  6. 计算新矩阵 “位移矩阵-原矩阵-缩放矩阵”,作为图形的 transform 值。

至此,我们就计算出新的 width、height、transform 矩阵了。

光标视口坐标转场景坐标。这个不提了,我写了太多文章,可简单理解为做视口矩阵的逆矩阵运算。

我们需要实现的算法为:

代码语言:javascript
复制
const resizeRect = (
  type, // 控制点类型
  newGlobalPt, // 当前光标的场景坐标
  rect, // 只有宽和高
  transform, // 矩阵
} = {
  // ...
}

然后是光标的场景坐标要转换为图形的本地坐标,只需要给光标点用图形的 transform 做一个逆矩阵

代码语言:javascript
复制
const newLocalPt = transform.applyInverse(newGlobalPt);

逆矩阵是个好东西,作用是做矩阵运算的相反操作。

比如一个点 A,做了矩阵变换变成了点 B。如果我们想回到 A 点,我们只需要求矩阵的逆矩阵,然后给 B 左乘上这个逆矩阵,就又得到了 A。

对于光标点,我们通过逆矩阵计算它 在图形变形前的位置

此时我们求在本地坐标系下,光标点到缩放中心的宽高。

这里要用绝对值,因为 width 和 height 不能为负数。

下面我们假设拖拽右下角(se)的控制点,则它的缩放点就在左上角(nw)。

对应代码:

代码语言:javascript
复制
// 不同控制点的缩放中心不同。
// 对于右下角控制点,缩放中心刚好是原点。
const localOrigin = {
  x: 0,
  y: 0,
};

const newWidth = Math.abs(newLocalPt.x - localOrigin.x);
const newHeight = Math.abs(newLocalPt.y - localOrigin.y);

虽然宽高不能为负数,但我们需要把负数带来的翻转效果,转换为一个缩放绝对值为 1 的缩放矩阵。

scaleX 如果是 1 表示不翻转,如果是 -1,表示水平翻转;scaleY 同理,不同的是它是垂直翻转。

如果 scaleX 和 scaleY 都是 -1,那它也代表一个旋转矩阵,旋转 180 度的旋转矩阵。

代码语言:javascript
复制
// scaleX 和 scaleY 的值为 1 或 -1,用于实现翻转
const scaleX = Math.sign(newLocalPt.x - localOrigin.x) || 1;
const scaleY = Math.sign(newLocalPt.y - localOrigin.y) || 1;
const scaleTransform = new Matrix().scale(scaleX, scaleY);

最后要做 x 和 y 的修正,确保缩放中心不发生移动。

这里我们分别计算场景坐标系下,缩放中心点的新旧点的位置,求差值,得到一个补正用的位移矩阵。

代码语言:javascript
复制
// 巧了这不是,还是原点
const newLocalOrigin = {
  x: 0,
  y: 0,
}
const newGlobalOrigin =
  newRect.transform.apply(newLocalOrigin);
const globalOrigin = transform.apply(localOrigin);

const offset = {
  x: globalOrigin.x - newGlobalOrigin.x,
  y: globalOrigin.y - newGlobalOrigin.y,
};
const translateTransform = new Matrix().translate(offset.x, offset.y)

最后所求矩阵为 “位移矩阵-原矩阵-缩放矩阵” 的复合矩阵:

代码语言:javascript
复制
const newTransform = translateTransform.append(transform).append(scaleTransform);

基本是这样。

其他控制点同理,就是缩放中心点不同,翻转的依据稍微有点不同。可以把不同的地方抽出为方法,用策略模式实现。

为什么要重新计算 width 和 height?

上面的缩放我们是改了图形的 width 和 height。

可能有读者朋友说我不改 width 和 height 行不行啊,用一个带缩放比的缩放矩阵,应该是等价的吧。

不太行。

这是因为如果图形有描边的话,strokeWidth 会受到 scale 的影响改变宽度,导致不同方向的描边不均匀。

看看动图演示:

完整代码

下面贴一下用 TypeScript 实现的完整代码。

该方法的矩阵运算逻辑使用了 Pixi.js 的 Matrix 矩阵类。

支持 8 种类型的控制点缩放,可设置是否要基于图形中点缩放、保持宽高比、不改宽高只改 transform。

代码语言:javascript
复制
import { Matrix } from 'pixi.js';

interface IPoint {
  x: number;
  y: number;
}

interface ITransformRect {
  width: number;
  height: number;
  transform: [number, number, number, number, number, number];
}

interface IResizeOp {
  getLocalOrigin(width: number, height: number): IPoint;
  getNewSize(
    newLocalPt: IPoint,
    localOrigin: IPoint,
    rect: { width: number; height: number },
  ): {
    width: number;
    height: number;
  };
  /**
   * 保持缩放比例时,是基于 width 还是 height 去计算新的 width height
   */
  isBaseWidthWhenKeepRatio(isWidthLarger: boolean): boolean;
  /**
   * 基于中心缩放时,对 size 进行修正
   */
  getSizeWhenScaleFromCenter(
    width: number,
    height: number,
  ): { width: number; height: number };
}

const doubleSize = (width: number, height: number) => ({
  width: width * 2,
  height: height * 2,
});

// 不同控制点对应的操作
const resizeOps: Record<string, IResizeOp> = {
  sw: {
    getLocalOrigin: (width: number) => ({ x: width, y: 0 }),
    getNewSize: (newLocalPt: IPoint, localOrigin: IPoint) => ({
      width: localOrigin.x - newLocalPt.x,
      height: newLocalPt.y - localOrigin.y,
    }),
    isBaseWidthWhenKeepRatio: (isWidthLarger: boolean) => isWidthLarger,
    getSizeWhenScaleFromCenter: doubleSize,
  },
  se: {
    getLocalOrigin: () => ({ x: 0, y: 0 }),
    getNewSize: (newLocalPt, localOrigin) => ({
      width: newLocalPt.x - localOrigin.x,
      height: newLocalPt.y - localOrigin.y,
    }),
    isBaseWidthWhenKeepRatio: (isWidthLarger: boolean) => isWidthLarger,
    getSizeWhenScaleFromCenter: doubleSize,
  },
  nw: {
    getLocalOrigin: (width, height) => {
      return { x: width, y: height };
    },
    getNewSize: (newLocalPt, localOrigin) => {
      return {
        width: localOrigin.x - newLocalPt.x,
        height: localOrigin.y - newLocalPt.y,
      };
    },
    isBaseWidthWhenKeepRatio: (isWidthLarger: boolean) => isWidthLarger,
    getSizeWhenScaleFromCenter: doubleSize,
  },
  ne: {
    getLocalOrigin: (_width, height) => ({ x: 0, y: height }),
    getNewSize: (newLocalPt, localOrigin) => ({
      width: newLocalPt.x - localOrigin.x,
      height: localOrigin.y - newLocalPt.y,
    }),
    isBaseWidthWhenKeepRatio: (isWidthLarger: boolean) => isWidthLarger,
    getSizeWhenScaleFromCenter: doubleSize,
  },
  n: {
    getLocalOrigin: (width, height) => ({ x: width / 2, y: height }),
    getNewSize: (newLocalPt, localOrigin, rect) => ({
      width: rect.width,
      height: localOrigin.y - newLocalPt.y,
    }),
    isBaseWidthWhenKeepRatio: () => false,
    getSizeWhenScaleFromCenter: (width, height) => ({
      width: width,
      height: height * 2,
    }),
  },
  s: {
    getLocalOrigin: (width) => ({ x: width / 2, y: 0 }),
    getNewSize: (newLocalPt, localOrigin, rect) => ({
      width: rect.width,
      height: newLocalPt.y - localOrigin.y,
    }),
    isBaseWidthWhenKeepRatio: () => false,
    getSizeWhenScaleFromCenter: (width, height) => ({
      width: width,
      height: height * 2,
    }),
  },
  e: {
    getLocalOrigin: (_width, height) => ({ x: 0, y: height / 2 }),
    getNewSize: (newLocalPt, localOrigin, rect) => ({
      width: newLocalPt.x - localOrigin.x,
      height: rect.height,
    }),
    isBaseWidthWhenKeepRatio: () => true,
    getSizeWhenScaleFromCenter: (width, height) => ({
      width: width * 2,
      height: height,
    }),
  },
  w: {
    getLocalOrigin: (width, height) => ({ x: width, y: height / 2 }),
    getNewSize: (newLocalPt, localOrigin, rect) => ({
      width: localOrigin.x - newLocalPt.x,
      height: rect.height,
    }),
    isBaseWidthWhenKeepRatio: () => true,
    getSizeWhenScaleFromCenter: (width, height) => ({
      width: width * 2,
      height: height,
    }),
  },
};

/**
 * get resized rect
 * used for resize operation
 */
export const resizeRect = (
  /** 'se' | 'ne' | 'nw' | 'sw' | 'n' | 'e' | 's' | 'w' */
  type: string,
  newGlobalPt: IPoint,
  rect: ITransformRect,
  options: {
    keepRatio?: boolean;
    scaleFromCenter?: boolean;
    noChangeWidthAndHeight?: boolean;
  } = {
    keepRatio: false,
    scaleFromCenter: false,
    noChangeWidthAndHeight: false,
  },
): ITransformRect => {
  const resizeOp = resizeOps[type];
  if (!resizeOp) {
    throw new Error(`resize type ${type} is invalid`);
  }
  const { keepRatio, scaleFromCenter } = options;

  const transform = new Matrix(...rect.transform);
  const newRect = {
    width: 0,
    height: 0,
    transform: transform.clone(),
  };

  const localOrigin = scaleFromCenter
    ? { x: rect.width / 2, y: rect.height / 2 }
    : resizeOp.getLocalOrigin(rect.width, rect.height);

  const newLocalPt = transform.applyInverse(newGlobalPt);
  let size = resizeOp.getNewSize(newLocalPt, localOrigin, rect);

  if (scaleFromCenter) {
    size = resizeOp.getSizeWhenScaleFromCenter(size.width, size.height);
  }

  if (keepRatio) {
    const ratio = rect.width / rect.height;
    const newRatio = Math.abs(size.width / size.height);
    const isWidthLarger = newRatio > ratio;
    if (resizeOp.isBaseWidthWhenKeepRatio(isWidthLarger)) {
      size.height = (Math.sign(size.height) * Math.abs(size.width)) / ratio;
    } else {
      size.width = Math.sign(size.width) * Math.abs(size.height) * ratio;
    }
  }

  const scaleTf = new Matrix();

  if (options.noChangeWidthAndHeight) {
    scaleTf.scale(size.width / rect.width, size.height / rect.height);
    newRect.width = rect.width;
    newRect.height = rect.height;
  } else {
    newRect.width = Math.abs(size.width);
    newRect.height = Math.abs(size.height);
    const scaleX = Math.sign(size.width) || 1;
    const scaleY = Math.sign(size.height) || 1;
    scaleTf.scale(scaleX, scaleY);
  }

  newRect.transform = newRect.transform.append(scaleTf);

  const newGlobalOrigin = newRect.transform.apply(
    scaleFromCenter
      ? { x: newRect.width / 2, y: newRect.height / 2 }
      : resizeOp.getLocalOrigin(newRect.width, newRect.height),
  );
  const globalOrigin = transform.apply(localOrigin);

  const offset = {
    x: globalOrigin.x - newGlobalOrigin.x,
    y: globalOrigin.y - newGlobalOrigin.y,
  };
  newRect.transform.prepend(new Matrix().translate(offset.x, offset.y));

  return {
    width: newRect.width,
    height: newRect.height,
    transform: [
      newRect.transform.a,
      newRect.transform.b,
      newRect.transform.c,
      newRect.transform.d,
      newRect.transform.tx,
      newRect.transform.ty,
    ],
  };
};

结尾

缩放单个图形,核心原理是通过逆矩阵得到图形本地坐标点,然后重新计算 width 和 height,并搭配一个用于翻转的缩放矩阵,和一个纠正位置的位移矩阵。

缩放多个图形稍微又有点点不一样,我们下篇文章再聊。

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

本文参与?腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2024-04-24,如有侵权请联系?cloudcommunity@tencent.com 删除

本文分享自 前端西瓜哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • transform 变形
  • 缩放控制点
  • 算法实现
  • 为什么要重新计算 width 和 height?
  • 完整代码
  • 结尾
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
http://www.vxiaotou.com