大家好,我是前端西瓜哥。
上一篇文章我们讲了为什么以及如何用 transform、width 和 height 表达图形。
这篇文章我们来看看基于 transform 的这种表达形式,要如何实现图形的缩放(resize)。
有一个图形。
它没做变形前是这样的,或者说如果矩阵是单位矩阵,它是这样的。
x 和 y 是 0。宽和高分别为 width、height。
注意我们不会使用 x 和 y 属性,我们会用矩阵的 tx 和 ty 表达位置,所以它们固定为原点位置。
然后我们给它应用一个 transform 矩阵,得到形变效果。
比如下面是先旋转 delta 度,然后移动 tx、ty 位置的效果。
矩阵的作用是,给点做一个线性变换使其映射到新的位置。
对于图形,其实就是将原来图形上的每一个点做了重映射,然后得到图形的缩放、旋转、位移等效果。
我们基于矩形的 4 个顶点,
(0, 0)
(width, 0)
(width, height)
(0, height)
做矩阵变换,得到 4 个缩放控制点。
我们将它们渲染出来,需要支持 通过拖拽它们,实现以对角为缩放中心,改变图形的尺寸。
resize 操作会更新三个属性:width、height 和 transform。
我们来看看算法实现。
缩放的总体思路为:
至此,我们就计算出新的 width、height、transform 矩阵了。
光标视口坐标转场景坐标。这个不提了,我写了太多文章,可简单理解为做视口矩阵的逆矩阵运算。
我们需要实现的算法为:
const resizeRect = (
type, // 控制点类型
newGlobalPt, // 当前光标的场景坐标
rect, // 只有宽和高
transform, // 矩阵
} = {
// ...
}
然后是光标的场景坐标要转换为图形的本地坐标,只需要给光标点用图形的 transform 做一个逆矩阵。
const newLocalPt = transform.applyInverse(newGlobalPt);
逆矩阵是个好东西,作用是做矩阵运算的相反操作。
比如一个点 A,做了矩阵变换变成了点 B。如果我们想回到 A 点,我们只需要求矩阵的逆矩阵,然后给 B 左乘上这个逆矩阵,就又得到了 A。
对于光标点,我们通过逆矩阵计算它 在图形变形前的位置。
此时我们求在本地坐标系下,光标点到缩放中心的宽高。
这里要用绝对值,因为 width 和 height 不能为负数。
下面我们假设拖拽右下角(se)的控制点,则它的缩放点就在左上角(nw)。
对应代码:
// 不同控制点的缩放中心不同。
// 对于右下角控制点,缩放中心刚好是原点。
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 度的旋转矩阵。
// 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 的修正,确保缩放中心不发生移动。
这里我们分别计算场景坐标系下,缩放中心点的新旧点的位置,求差值,得到一个补正用的位移矩阵。
// 巧了这不是,还是原点
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)
最后所求矩阵为 “位移矩阵-原矩阵-缩放矩阵” 的复合矩阵:
const newTransform = translateTransform.append(transform).append(scaleTransform);
基本是这样。
其他控制点同理,就是缩放中心点不同,翻转的依据稍微有点不同。可以把不同的地方抽出为方法,用策略模式实现。
上面的缩放我们是改了图形的 width 和 height。
可能有读者朋友说我不改 width 和 height 行不行啊,用一个带缩放比的缩放矩阵,应该是等价的吧。
不太行。
这是因为如果图形有描边的话,strokeWidth 会受到 scale 的影响改变宽度,导致不同方向的描边不均匀。
看看动图演示:
下面贴一下用 TypeScript 实现的完整代码。
该方法的矩阵运算逻辑使用了 Pixi.js 的 Matrix 矩阵类。
支持 8 种类型的控制点缩放,可设置是否要基于图形中点缩放、保持宽高比、不改宽高只改 transform。
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,并搭配一个用于翻转的缩放矩阵,和一个纠正位置的位移矩阵。
缩放多个图形稍微又有点点不一样,我们下篇文章再聊。
我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。