前言
源码
学习目标
- 创建ImgControler对象
- 使用ImgControler对象变换图案
知识点
- 图案选择
- 图案控制框
- 鼠标状态与样式
- 图案变换
前情回顾
之前我们用OrbitControler 对象实现了相机的变换,接下来我们建立ImgControler对象。
1-ImgControler对象的功能分析
ImgControler对象具备以下功能:
1.在选中的图案上显示控制框。
2.鼠标在图案上移动时,检测变换状态(位移、旋转、缩放),并改变鼠标图案。
3.位移、旋转、缩放图案。
4.Esc 取消变换。
5.Enter 或点击空白处时确认变换。
因为ImgControler 对象的功能比较复杂,所以我们需要一步步实现。
2-图案选择
2-1-建立ImgControler对象
ImgControler对象只负责变换图案,不负责选择图案。
- /src/lmm/controler/ImgControler.ts
import { Vector2 } from '../math/Vector2'
import { Object2D, Object2DType } from '../objects/Object2D'
import { Img } from '../objects/Img'
import { Matrix3 } from '../math/Matrix3'
import { Scene } from '../core/Scene'
const _changeEvent = { type: 'change' }
class ImgControler extends Object2D {
// 要控制的图片
img: Img | null = null
/* 鼠标按下 */
pointerdown(img: Img | null, mp: Vector2) {
this.img = img
if (!this.img) {
return
}
console.log('选中图案', this.img.name)
this.dispatchEvent(_changeEvent)
}
}
export { ImgControler }
当前的ImgControler对象只有一个img 属性,表示选中的图案。
当鼠标按下时,会执行pointerdown(),其参数img 是在外部选择的。
2-2-建立ImgControler.vue 测试页
整体代码如下:
- /src/examples/ImgControler.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ImgControler } from '../lmm/controler/ImgControler'
import { OrbitControler } from '../lmm/controler/OrbitControler'
import { Scene } from '../lmm/core/Scene'
import { Vector2 } from '../lmm/math/Vector2'
import { Group } from '../lmm/objects/Group'
import { Img } from '../lmm/objects/Img'
import { ImagePromises } from '../lmm/objects/ObjectUtils'
import { Object2D } from '../lmm/objects/Object2D'
// 获取父级属性
defineProps({
size: { type: Object, default: { width: 0, height: 0 } },
})
// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>()
/* 场景 */
const scene = new Scene()
/* 相机轨道控制器 */
const orbitControler = new OrbitControler(scene.camera)
/* 图案控制器 */
const imgControler = new ImgControler()
scene.add(imgControler)
const images: HTMLImageElement[] = []
for (let i = 1; i < 5; i++) {
const image = new Image()
image.src = `https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/${i}.png`
images.push(image)
}
/* 图案集合 */
const imgGroup = new Group()
scene.add(imgGroup)
/* 鼠标滑上的图案 */
let imgHover: Img | null
/* 测试 */
function test(canvas: HTMLCanvasElement) {
/* 添加图案 */
imgGroup.add(
...images.map((image, i) => {
const size = new Vector2(image.width, image.height).multiplyScalar(0.3)
return new Img({
image,
position: new Vector2(0, 160 * i - canvas.height / 2 + 50),
size,
offset: new Vector2(-size.x / 2, 0),
name: 'img-' + i,
style: {
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 5,
shadowOffsetY: 20,
},
})
})
)
/* 鼠标按下*/
canvas.addEventListener('pointerdown', (event: PointerEvent) => {
const { button, clientX, clientY } = event
const mp = scene.clientToClip(clientX, clientY)
switch (button) {
case 0:
imgHover = selectObj(imgGroup.children, mp)
imgControler.pointerdown(imgHover, mp)
break
case 1:
orbitControler.pointerdown(clientX, clientY)
break
}
})
/* 鼠标移动 */
canvas.addEventListener('pointermove', (event: PointerEvent) => {
orbitControler.pointermove(event.clientX, event.clientY)
})
/* 鼠标抬起 */
window.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button == 1) {
orbitControler.pointerup()
}
})
/* 滑动滚轮缩放 */
canvas.addEventListener('wheel', ({ deltaY }) => {
orbitControler.doScale(deltaY)
})
/* 按需渲染 */
orbitControler.addEventListener('change', () => {
scene.render()
})
imgControler.addEventListener('change', () => {
scene.render()
})
/* 渲染 */
scene.render()
}
/* 选择图案 */
function selectObj(imgGroup: Object2D[], mp: Vector2): Img | null {
for (let img of [...imgGroup].reverse()) {
if (img instanceof Img && scene.isPointInObj(img, mp, img.pvmoMatrix)) {
return img
}
}
return null
}
onMounted(() => {
const canvas = canvasRef.value
if (canvas) {
scene.setOption({ canvas })
Promise.all(ImagePromises(images)).then(() => {
test(canvas)
})
}
})
</script>
<template>
<canvas ref="canvasRef" :width="size.width" :height="size.height"></canvas>
</template>
<style scoped></style>
效果如下:
当鼠标点击图案时,就会打印出相应图案的名称。
selectObj()方法是在裁剪坐标系中实现的,因此需要把鼠标的client位置转裁剪位,把图案路径从本地偏移坐标系转到裁剪坐标系绘制,然后在用ctx.isPointInPath() 判断鼠标点是否在图形中。
接下来我们把图案的控制框显示出来。
3-图案控制框
图案控制框处于渲染最顶层,由线框、矩形节点和基点组成。
因为我们在变换图案时,用的是canvas 内置的变换方法,为避免pvm矩阵缩放描边宽度,所以控制框需要放在裁剪空间中。
对于把控制框放裁剪空间的方法,我们将其放在世界坐标系中,然后使其不受相机影响即可。
1.单独建立一个Frame控制框对象。
- /src/lmm/controler/Frame.ts
import { Matrix3 } from '../math/Matrix3'
import { Vector2 } from '../math/Vector2'
import { Img } from '../objects/Img'
import { crtPath, crtPathByMatrix } from '../objects/ObjectUtils'
const pi2 = Math.PI * 2
//在哪个坐标系绘制控制框
//moMatrix:偏移坐标转世界坐标
//pvmoMatrix:偏移坐标转裁剪坐标
type Leve = 'moMatrix' | 'pvmoMatrix'
//参数类型
type FrameType = {
img?: Img
level?: Leve
}
class Frame {
_img = new Img()
// 图案边框的顶点集合
vertives: number[] = []
// 图案中点
center = new Vector2()
// 路径变换矩阵
matrix = new Matrix3()
// 要把路径变换到哪个坐标系中,默认裁剪坐标系
level = 'pvmoMatrix'
// 描边色
strokeStyle = '#558ef0'
// 填充色
fillStyle = '#fff'
constructor(attr: FrameType = {}) {
for (let [key, val] of Object.entries(attr)) {
this[key] = val
}
}
get img() {
return this._img
}
set img(val) {
this._img = val
this.updateShape()
}
/* 更新矩阵、路径初始顶点、中点 */
updateShape() {
const {
vertives: fv,
center,
img,
level,
img: {
size: { x: imgW, y: imgH },
},
} = this
const vertices = [
0,0,
imgW / 2,0,
imgW,0,
imgW,imgH / 2,
imgW,imgH,
imgW / 2,imgH,
0,imgH,
0,imgH / 2,
]
/* 更新路径变换矩阵 */
this.matrix = img[level]
for (let i = 0, len = vertices.length; i < len; i += 2) {
const { x, y } = new Vector2(vertices[i], vertices[i + 1]).applyMatrix3(
this.matrix
)
/* 更新路径顶点 */
fv[i] = x
fv[i + 1] = y
}
/* 更新中点 */
center.copy(new Vector2(fv[0], fv[1]).lerp(new Vector2(fv[8], fv[9]), 0.5))
}
draw(ctx: CanvasRenderingContext2D) {
this.updateShape()
const {
img: { size },
vertives: fv,
center,
matrix,
strokeStyle,
fillStyle,
} = this
/* 图案尺寸的一半 */
const [halfWidth, halfheight] = [size.width / 2, size.height / 2]
/* 绘图 */
ctx.save()
ctx.strokeStyle = strokeStyle
ctx.fillStyle = fillStyle
/* 矩形框 */
ctx.beginPath()
crtPath(
ctx,
[fv[0], fv[1], fv[4], fv[5], fv[8], fv[9], fv[12], fv[13]],
true
)
ctx.stroke()
/* 矩形节点 */
const { elements: e } = matrix
// 矩阵内的缩放量
const sx = new Vector2(e[0], e[1]).length()
const sy = new Vector2(e[3], e[4]).length()
// 节点尺寸,消去缩放量
const pointSize = new Vector2(8 / sx, 8 / sy)
const [w, h] = [pointSize.x / 2, pointSize.y / 2]
// 绘制节点
ctx.beginPath()
for (let y = 0; y < 3; y++) {
for (let x = 0; x < 3; x++) {
if (y === 1 && x === 1) {
continue
}
const [bx, by] = [halfWidth * x, halfheight * y]
crtPathByMatrix(
ctx,
[bx - w, by - h, bx + w, by - h, bx + w, by + h, bx - w, by + h],
matrix,
true
)
}
}
ctx.fill()
ctx.stroke()
/* 中点 */
ctx.beginPath()
ctx.arc(center.x, center.y, 5, 0, pi2)
ctx.fill()
ctx.stroke()
ctx.restore()
}
}
export { Frame }
crtPath() 是创建路径的方法,crtPathByMatrix() 是基于矩阵创建路径的方法,因为这个两个方法用得比较多,所以就封装到了一个工具库中。
- /src/lmm/objects/ObjectUtils.ts
import { Matrix3 } from '../math/Matrix3'
import { Vector2 } from '../math/Vector2'
/* 基于矩阵创建路径 */
function crtPathByMatrix(
ctx: CanvasRenderingContext2D,
vertices: number[],
matrix: Matrix3,
closePath = false
) {
const p0 = new Vector2(vertices[0], vertices[1]).applyMatrix3(matrix)
ctx.moveTo(p0.x, p0.y)
for (let i = 2, len = vertices.length; i < len; i += 2) {
const pn = new Vector2(vertices[i], vertices[i + 1]).applyMatrix3(matrix)
ctx.lineTo(pn.x, pn.y)
}
closePath && ctx.closePath()
}
/* 创建路径 */
function crtPath(
ctx: CanvasRenderingContext2D,
vertices: number[],
closePath = false
) {
const p0 = new Vector2(vertices[0], vertices[1])
ctx.moveTo(p0.x, p0.y)
for (let i = 2, len = vertices.length; i < len; i += 2) {
const pn = new Vector2(vertices[i], vertices[i + 1])
ctx.lineTo(pn.x, pn.y)
}
closePath && ctx.closePath()
}
/* 加载图案的Promise对象 */
function ImagePromise(image: HTMLImageElement) {
return new Promise<HTMLImageElement>((resolve) => {
image.onload = () => {
resolve(image)
}
})
}
/* 图案的批量加载 */
function ImagePromises(images: HTMLImageElement[]) {
return images.map((image) => ImagePromise(image))
}
export { crtPath, crtPathByMatrix, ImagePromise, ImagePromises }
2.把Frame 对象放到ImgControler 中绘制。
import { Vector2 } from '../math/Vector2'
import { Object2D, Object2DType } from '../objects/Object2D'
import { Img } from '../objects/Img'
import { Matrix3 } from '../math/Matrix3'
import { Scene } from '../core/Scene'
import { ImgTransformer } from './ImgTransformer'
import { MouseShape } from './MouseShape'
import { Frame } from './Frame'
// change 事件
const _changeEvent = { type: 'change' }
class ImgControler extends Object2D {
// 要控制的图片
_img: Img | null = null
// 图案控制框
frame = new Frame()
// 渲染顺序
index = Infinity
// 不受相机影响
enableCamera = false
get img() {
return this._img
}
set img(val) {
if (this._img === val) {
return
}
this._img = val
if (val) {
this.frame.img = val
this.dispatchEvent({ type: 'selected', img:val })
}
this.dispatchEvent(_changeEvent)
}
/* 鼠标按下 */
pointerdown(imgGroup: Object2D[], mp: Vector2) {
……
}
/* 绘图 */
draw(ctx: CanvasRenderingContext2D) {
console.log('draw')
const { img } = this
if (!img) {
return
}
const { frame } = this
/* 绘制外框 */
frame.draw(ctx)
}
}
export { ImgControler }
在上面的代码中,我为ImgControler 对象增加了frame属性。
我通过set 赋值器兼容ImgControler中img的变化,将其img 同步到frame中。
最后添加一个draw() 绘图方法,在中绘制frame。
因为ImgControler的index为Infinity,所以ImgControler会显示在所有物体之上。
draw() 方法会在渲染的时候自动执行。
接下来我们再监听鼠标的变换状态。
4-鼠标的变换状态
鼠标具有以下状态:
-
缩放
- scale:x,y 两个方向的缩放
- scaleX:x 方向缩放
- scaleY:y 方向缩放
-
旋转 rotate
-
移动 move
-
无状态 null
在Frame对象中添加一个获取鼠标变换状态的方法。
……
/* 鼠标状态 */
export type State =
| 'scale'
| 'scaleX'
| 'scaleY'
| 'rotate'
| 'move'
| null
/* 布尔变量 */
let _bool: Boolean = false
/* 虚拟canvas上下文对象 */
const ctx = document
.createElement('canvas')
.getContext('2d') as CanvasRenderingContext2D
class Frame {
……
/* 获取变换状态 */
getMouseState(mp: Vector2): State {
const { vertives: fv } = this
/* 对角线距离 */
const diagonal = new Vector2(fv[0] - fv[8], fv[1] - fv[9]).length()
/* 判断缩放的距离 */
const scaleDist = Math.min(24, diagonal / 3)
/* x,y缩放 */
for (let i = 0, len = fv.length; i < len; i += 4) {
if (new Vector2(fv[i], fv[i + 1]).sub(mp).length() < scaleDist) {
const ind = (i + 8) % 16
return 'scale'
}
}
/* y向缩放 */
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[0], fv[1], fv[4], fv[5]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'scaleY'
}
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[8], fv[9], fv[12], fv[13]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'scaleY'
}
/* x向缩放 */
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[12], fv[13], fv[0], fv[1]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'scaleX'
}
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[4], fv[5], fv[8], fv[9]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'scaleX'
}
/* 移动 */
ctx.beginPath()
crtPath(ctx, fv)
if (ctx.isPointInPath(mp.x, mp.y)) {
return 'move'
}
/* 旋转 */
ctx.save()
ctx.lineWidth = 80
ctx.beginPath()
crtPath(ctx, fv)
ctx.closePath()
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'rotate'
}
/* 无状态 */
return null
}
}
export { Frame }
解释一下其状态的判断逻辑。
- scale:鼠标到矩形四个顶点的距离,这个距离是根据矩形的对角线做的判断。
/* 对角线距离 */
const diagonal = new Vector2(fv[0] - fv[8], fv[1] - fv[9]).length()
/* 判断缩放的距离 */
const scaleDist = Math.min(24, diagonal / 3)
- y向缩放:鼠标到矩形两条鼠标的距离,这个距离是把竖边加粗后,用isPointInStroke() 方法判断的。
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[0], fv[1], fv[4], fv[5]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'scaleY'
}
- x向缩放与y向缩放同理。
- move:判断鼠标是否在矩形中。
ctx.beginPath()
crtPath(ctx, fv)
if (ctx.isPointInPath(mp.x, mp.y)) {
return 'move'
}
- rotate:根据鼠标到矩形的距离判断,把矩形描边加粗后用isPointInStroke() 方法判断。
ctx.save()
ctx.lineWidth = 80
ctx.beginPath()
crtPath(ctx, fv)
ctx.closePath()
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
return 'rotate'
}
接下来我们在ImgControler 中测试一下getTransformState() 方法。
1.给ImgControler添加了一个mouseState 属性,然后在鼠标单击和移动的时候用frame.getMouseState(mp) 方法获取鼠标变换状态。
……
import { Frame, State } from './Frame'
class ImgControler extends Object2D {
……
// 鼠标状态
mouseState: State = null
……
/* 鼠标按下 */
pointerdown(img: Img | null, mp: Vector2) {
if (!this.mouseState) {
this.img = img
if (!img) {
return
}
}
// 获取鼠标状态
this.mouseState = this.frame.getMouseState(mp)
this.dispatchEvent(_changeEvent)
}
/* 鼠标移动 */
pointermove(mp: Vector2) {
if (!this.img) {
return
}
// 获取鼠标状态
this.mouseState = this.frame.getMouseState(mp)
console.log('mouseState', this.mouseState)
this.dispatchEvent(_changeEvent)
}
}
export { ImgControler }
2.在ImgControler.vue 中的鼠标移动事件中执行ImgControler对象的pointermove() 方法。
canvas.addEventListener('pointermove', (event: PointerEvent) => {
const { clientX, clientY } = event
orbitControler.pointermove(clientX, clientY)
const mp = scene.clientToClip(clientX, clientY)
imgControler.pointermove(mp)
})
上面的mp是鼠标在裁剪坐标系中的位置。
现在运行项目,当鼠标放在图案控制器框的不同位置时,便会打印出相应的状态。
接下来,我们在鼠标状态发生改变时,改变鼠标样式。
5-绘制鼠标变换样式
鼠标有三种变换样式,分别是移动、旋转和缩放。
我为了方便管理,新建了一个绘制鼠标样式的类-MouseShape.
整体代码如下:
- /src/lmm/controler/MouseShape.ts
import { State } from './Frame'
import { Vector2 } from '../math/Vector2'
import { crtPath } from '../objects/ObjectUtils'
type MouseShapeType = {
fillStyle?: string
strokeStyle?: string
mousePos?: Vector2
center?: Vector2
vertives?: number[]
moveVertices?: number[]
rotateVertices?: number[]
scaleVertices?: number[]
}
class MouseShape {
// 鼠标位置
mousePos = new Vector2()
// 图案中心位
center = new Vector2()
// 图案边框的顶点集合
vertives: number[] = []
// 移动图案
moveVertices: number[] = [0, 0, 14, 14, 6, 14, 0, 20]
// 旋转图案,由[-15, 0, -9, -5, -9, -1, -5, -1, -1, 1, 1, 5, 1, 9, 5, 9, 0, 15, -5, 9, -1,9, -1, 5, -2.2, 2.2, -5, 1, -9, 1, -9, 5]旋转45°得来
rotateVertices: number[] = [
-10.61, -10.61, -2.83, -9.9, -5.66, -7.07, -2.83, -4.24, -1.41, 0, -2.83,
4.24, -5.66, 7.07, -2.83, 9.9, -10.61, 10.61, -9.9, 2.83, -7.07, 5.66,
-4.24, 2.83, -3.11, 0, -4.24, -2.83, -7.07, -5.66, -9.9, -2.83,
]
// 缩放图案
scaleVertices: number[] = [
1, 4, 1, 1, 5, 1, 5, 5, 11, 0, 5, -5, 5, -1, 1, -1, 1, -4, -1, -4, -1, -1,
-5, -1, -5, -5, -11, 0, -5, 5, -5, 1, -1, 1, -1, 4,
]
fillStyle = '#000'
strokeStyle = '#fff'
constructor(attr: MouseShapeType = {}) {
Object.assign(this, attr)
}
// scale状态
scale(ctx: CanvasRenderingContext2D) {
const { mousePos, center } = this
this.drawScale(ctx, new Vector2().subVectors(center, mousePos).angle())
}
// scaleY状态
scaleY(ctx: CanvasRenderingContext2D) {
const { center, vertives } = this
this.drawScale(
ctx,
new Vector2()
.subVectors(center, new Vector2(vertives[2], vertives[3]))
.angle()
)
}
// scaleX 状态
scaleX(ctx: CanvasRenderingContext2D) {
const { center, vertives } = this
this.drawScale(
ctx,
new Vector2()
.subVectors(center, new Vector2(vertives[14], vertives[15]))
.angle()
)
}
// 移动状态
move(ctx: CanvasRenderingContext2D) {
ctx.beginPath()
crtPath(ctx, this.moveVertices)
}
// 旋转状态
rotate(ctx: CanvasRenderingContext2D) {
const { mousePos, center } = this
ctx.rotate(new Vector2().subVectors(mousePos, center).angle())
ctx.beginPath()
crtPath(ctx, this.rotateVertices)
}
drawScale(ctx: CanvasRenderingContext2D, ang: number) {
ctx.rotate(ang)
ctx.beginPath()
crtPath(ctx, this.scaleVertices)
}
draw(ctx: CanvasRenderingContext2D, state: State) {
if (!state) {
return
}
const { mousePos, fillStyle, strokeStyle } = this
ctx.save()
ctx.fillStyle = fillStyle
ctx.strokeStyle = strokeStyle
ctx.lineWidth = 2
ctx.translate(mousePos.x, mousePos.y)
this[state](ctx)
ctx.closePath()
ctx.stroke()
ctx.fill()
ctx.restore()
}
}
export { MouseShape }
解释一下上面的代码。
mousePos、center、vertives属性是要从ImgControler中获取的,用于鼠标图案的旋转。鼠标的旋转和缩放图案在不同的位置mousePos,会围绕图案的中心点center 做不同的旋转。
moveVertices、rotateVertices、scaleVertices 是鼠标三种图案的顶点集合,这些顶点是我自己画出一张草图后,一个个算出来的。
接下来咱们看一下变换图案的具体实现。
1.scale:绘制缩放图案,并根据图案中心点到鼠标的方向旋转图案。
scale(ctx: CanvasRenderingContext2D) {
const { mousePos, center } = this
this.drawScale(ctx, new Vector2().subVectors(center, mousePos).angle())
}
drawScale(ctx: CanvasRenderingContext2D, ang: number) {
ctx.rotate(ang)
ctx.beginPath()
crtPath(ctx, this.scaleVertices)
}
crtPath(ctx: CanvasRenderingContext2D, vertices: number[]) {
const p0 = new Vector2(vertices[0], vertices[1])
ctx.moveTo(p0.x, p0.y)
for (let i = 2, len = vertices.length; i < len; i += 2) {
const pn = new Vector2(vertices[i], vertices[i + 1])
ctx.lineTo(pn.x, pn.y)
}
}
上面的路径绘制过程都是基础,我便不再多说。
2.scaleY:绘制缩放图案,并根据图案中心点到任意横边中点的方向旋转图案。
scaleY(ctx: CanvasRenderingContext2D) {
const { center, vertives } = this
this.drawScale(
ctx,
new Vector2()
.subVectors(center, new Vector2(vertives[2], vertives[3]))
.angle()
)
}
vertives 顶点索引与控制框的节点对应关系如下:
3.scaleX:绘制缩放图案,并根据图案中心点到任意竖边中点的方向旋转图案。
scaleX(ctx: CanvasRenderingContext2D) {
const { center, vertives } = this
this.drawScale(
ctx,
new Vector2()
.subVectors(center, new Vector2(vertives[14], vertives[15]))
.angle()
)
}
4.move:绘制位移图案。
move(ctx: CanvasRenderingContext2D) {
ctx.beginPath()
crtPath(ctx, this.moveVertices)
}
5.rotate:绘制旋转图案,并根据图案中心点到鼠标的方向旋转图案。
rotate(ctx: CanvasRenderingContext2D) {
const { mousePos, center } = this
ctx.rotate(new Vector2().subVectors(mousePos, center).angle())
ctx.beginPath()
crtPath(ctx, this.rotateVertices)
}
关于鼠标状态的具体绘制方法就是这样。
我当前画的都是路径,接下我们再建立一个整体的绘图方法。
draw(ctx: CanvasRenderingContext2D, state: State) {
if (!state) {
return
}
const { mousePos, fillStyle, strokeStyle } = this
ctx.save()
ctx.fillStyle = fillStyle
ctx.strokeStyle = strokeStyle
ctx.lineWidth = 2
ctx.translate(mousePos.x, mousePos.y)
this[state](ctx)
ctx.closePath()
ctx.stroke()
ctx.fill()
ctx.restore()
}
draw() 方法会整体设置鼠标图案的样式和位置,然后根据state 参数绘制相应的鼠标图案。
当前的MouseShape对象算是策略模式的一种,通过this[state]调用相应的方法,可以省去if,else或者switch 的判断,从而简化代码,提高渲染效率。
接下来,我们在ImgControler对象中引入MouseShape。
……
class ImgControler extends Object2D {
……
// 鼠标的裁剪坐标位
clipMousePos = new Vector2()
// 鼠标图案
mouseShape = new MouseShape({
vertives: this.frame.vertives,
center: this.frame.center,
mousePos: this.clipMousePos,
})
/* 鼠标按下 */
pointerdown(img: Img | null, mp: Vector2) {
if (!this.mouseState) {
this.img = img
if (!img) {
return
}
}
// 更新鼠标裁剪坐标位
this.clipMousePos.copy(mp)
// 获取鼠标状态
this.mouseState = this.frame.getMouseState(mp)
this.dispatchEvent(_changeEvent))
}
/* 鼠标移动 */
pointermove(mp: Vector2) {
if (!this.img) {
return
}
// 更新鼠标裁剪坐标位
this.clipMousePos.copy(mp)
// 获取鼠标状态
this.mouseState = this.frame.getMouseState(mp)
console.log('mouseState', this.mouseState)
this.dispatchEvent(_changeEvent)
}
/* 绘图 */
draw(ctx: CanvasRenderingContext2D) {
const { img } = this
if (!img) {
return
}
const { frame, mouseShape, mouseState } = this
/* 绘制外框 */
frame.draw(ctx)
/* 绘制鼠标图案 */
mouseShape.draw(ctx, mouseState)
}
}
export { ImgControler }
解释一下上面的代码。
1.为ImgControle对象添加了两个属性:
- clipMousePos:鼠标的裁剪坐标位,在鼠标按下和移动的时候更新此位置。
- mouseShape:MouseShape对象,在其实例化时传递了三个参数vertives、center、mousePos。因为它们都是复合类型的数据,所以当它们内部数据发生改变时,mouseShape获取到的相应数据也会改变。
mouseShape = new MouseShape({
vertives: this.frame.vertives,
center: this.frame.center,
mousePos: this.clipMousePos,
})
为了防止vertives、center、mousePos 被重新定义,如下所示:
frame.vertives=[……]
我们可以将它们设置为只读。
class ImgControler extends Object2D {
// 鼠标的裁剪坐标位
readonly clipMousePos = new Vector2()
……
}
class Frame {
// 图案边框的顶点集合
readonly vertives: number[] = []
// 图案中点
readonly center = new Vector2()
……
}
2.在draw 方法中,绘制mouseShape,mouseShape会根据传入的鼠标状态绘制相应的图案。
draw(ctx: CanvasRenderingContext2D) {
const { img } = this
if (!img) {
return
}
const { frame, mouseShape, mouseState } = this
/* 绘制外框 */
frame.draw(ctx)
/* 绘制鼠标图案 */
mouseShape.draw(ctx, mouseState)
}
现在当我们把鼠标放图案控制框上,就可以看到相应的鼠标图案:
不过默认鼠标还在,需要将其隐藏。
在ImgControler.vue 中设置canvas画布上的cursor 属性。
<script setup lang="ts">
……
// 鼠标样式
const cursor = ref('default')
/* 测试 */
function test(canvas: HTMLCanvasElement) {
……
/* 鼠标按下*/
canvas.addEventListener('pointerdown', (event: PointerEvent) => {
……
switch (button) {
case 0:
……
updateMouseCursor()
break
……
}
})
/* 鼠标移动 */
canvas.addEventListener('pointermove', (event: PointerEvent) => {
……
updateMouseCursor()
})
……
}
/* 更新鼠标样式 */
function updateMouseCursor() {
if (imgControler.mouseState) {
cursor.value = 'none'
} else if (imgHover) {
cursor.value = 'pointer'
} else {
cursor.value = 'default'
}
}
……
</script>
<template>
<canvas
ref="canvasRef"
:style="{ cursor }"
:width="size.width"
:height="size.height"
></canvas>
</template>
在上面的代码中,我定义了ref类型的cursor 变量,并将其绑定到了canvas的style 中。
updateMouseCursor() 方法使其根据鼠标状态做更新cursor。
鼠标按下和移动时会执行updateMouseCursor() 方法。
运行项目,效果如下:
- scale
- scaleX
- scaleY
- move
- rotate
关于鼠标变换样式就说到这,接下来我们开始变换图案。
6-变换图案
6-1-变换图案的方式
图案变换有旋转、缩放、位移三种状态,并且会根据alt、shift键的按下,有不同的变换方式。
-
缩放:
- 默认:以对面节点为基点缩放
- shift:等比缩放
- alt:以图案中心为基点缩放
-
旋转:
- 默认:以图案中心为基点旋转
- shift:等量旋转
-
位移
- 默认:鼠标按下后,随鼠标移动
- shift:基于世界坐标系,正交位移
在上面的缩放中,默认以对面节点为基点缩放,为了操作方便,我可以在Frame 对象中存储一个对面节点opposite。
class Frame {
……
// 对面节点
opposite = new Vector2()
……
/* 获取变换状态 */
getMouseState(mp: Vector2): State {
……
/* x,y缩放 */
for (let i = 0, len = fv.length; i < len; i += 4) {
if (new Vector2(fv[i], fv[i + 1]).sub(mp).length() < scaleDist) {
const ind = (i + 8) % 16
opposite.set(fv[ind], fv[ind + 1])
return 'scale'
}
}
/* y向缩放 */
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[0], fv[1], fv[4], fv[5]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
opposite.set(fv[10], fv[11])
return 'scaleY'
}
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[8], fv[9], fv[12], fv[13]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
opposite.set(fv[2], fv[3])
return 'scaleY'
}
/* x向缩放 */
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[12], fv[13], fv[0], fv[1]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
opposite.set(fv[6], fv[7])
return 'scaleX'
}
ctx.save()
ctx.lineWidth = scaleDist
ctx.beginPath()
crtPath(ctx, [fv[4], fv[5], fv[8], fv[9]])
_bool = ctx.isPointInStroke(mp.x, mp.y)
ctx.restore()
if (_bool) {
opposite.set(fv[14], fv[15])
return 'scaleX'
}
……
}
}
export { Frame }
在获取缩放状态时,会将对面节点存储到opposite中。
接下来我们建立一个专注于图案变换的对象。
6-2-建立ImgTransformer 对象
ImgTransformer 对象会专注于图案变换功能的实现。
我们先简单建立一个ImgTransformer 对象。
- /src/lmm/controler/ImgTransformer.ts
import { Vector2 } from '../math/Vector2'
import { Img } from '../objects/Img'
/* PI*2 */
const pi2 = Math.PI * 2
/* 图案数据类型 */
type ImgData = {
position: Vector2
scale: Vector2
rotate: number
offset: Vector2
}
type ImgTransformerType = {
img?: Img
orign?: Vector2
mousePos?: Vector2
mouseStart?: Vector2
uniformRotateAng?: number
}
class ImgTransformer {
/* 变换图案 */
img = new Img()
/* 暂存图案的变换信息 */
position = new Vector2()
scale = new Vector2(1, 1)
rotate = 0
offset = new Vector2()
/* 图案的变换基点 */
orign = new Vector2()
/* 图案父级坐标系里的鼠标数据 */
// 鼠标位置
mousePos = new Vector2()
// 鼠标起始位
mouseStart = new Vector2()
// mouseStart减orign
originToMouseStart = new Vector2()
/* 等量旋转时的旋转弧度 */
uniformRotateAng = pi2 / 24
constructor(attr: ImgTransformerType = {}) {
this.setOption(attr)
}
/* 设置属性 */
setOption(attr: ImgTransformerType = {}) {
Object.assign(this, attr)
const { img, mouseStart, orign } = attr
img && this.passImgDataTo()
if (orign || mouseStart) {
this.updateOriginToMouseStart(mouseStart, orign)
}
}
/* 变换基点到鼠标起点的向量 */
updateOriginToMouseStart(mouseStart = this.mouseStart, orign = this.orign) {
this.originToMouseStart.subVectors(mouseStart, orign)
}
/* 把img变换数据传递给obj */
passImgDataTo(obj: ImgData = this) {
const { position, scale, rotate, offset } = this.img
obj.position.copy(position)
obj.scale.copy(scale)
obj.rotate = rotate
obj.offset.copy(offset)
}
/* 双向缩放 */
scale0() {}
/* 双向等比缩放 */
scale1() {}
/* 单向缩放 */
scaleX0() {}
scaleY0() {}
/* 单向等比缩放 */
scaleX1() {}
scaleY1() {}
/* 旋转 */
rotate0() {}
/* 等量旋转 */
rotate1() {}
/* 位移 */
// 自由位移
move0() {}
// 正交位移-作业,留给同学们实现
move1() {}
}
export { ImgTransformer }
在上面的代码中,我们罗列出了ImgTransformer 所需的属性,并建立了空的变换方法,这些方法我会在后面一一实现。
ImgControler对象会向ImgTransformer 对象传入img图案,这时ImgTransformer对象会暂存img 的变换信息-position、scale、rotate、offset,之后会在此基础上加上新的变换量。
当我们用鼠标变换图案时,会把鼠标的client坐标转换到图案的父级坐标系中,然后根据鼠标的变换,变换图案。
在此强调一下,图案的所有变换数据都是从图案的父级坐标系中取的。鼠标的坐标信息也需要下沉到图案父级坐标系。
对于鼠标坐标信息转换的方法,我们会在ImgControler中实现,这样ImgTransformer 对象会更专注于图案变换。
接下来,我们在ImgControler 对象中引入ImgTransformer。
6-3-在ImgControler中操控图案
ImgControler主要负责坐标转换,对接鼠标事件和键盘事件,使用ImgTransformer变换图案,控制图案变换的确认和取消,以及删除图案。
ImgControler的具体操作步骤如下:
-
当选中图案时:
- 把图案传递给imgTransformer。
- 把图案的变换信息存储到controlState中,以便按下Esc时取消变换。
-
当鼠标按下时:
- 记录图案控制状态。
- 更新鼠标在图案父级坐标系里的坐标位。
-
当控制状态controlState 发生改变时:
-
暂存变换数据
- clipCenter 图案在裁剪坐标系中的中点。
- clipOpposite 裁剪坐标系里的对点,作为默认缩放的基点。
- parentPvmInvert 图案父级pvm矩阵的逆矩阵,用于将裁剪坐标位转图案父级坐标位。
- 把图案变换信息和鼠标位置更新到imgTransformer中。
-
根据控制状态更新图案变换基点 origin。
- 缩放:默认基点在对面,按住alt键时,在图案中心。
- 旋转:基点在图案中心。
-
根据变换基点,偏移图案。
-
-
鼠标移动时:
- 更新鼠标在图案父级坐标系中的位置
- 变换图案
-
鼠标抬起时,取消控制状态controlState
-
当按下alt键时,缩放基点在图案中心。
-
当按下shift 键时,更新变换方式。
- 等比缩放
- 等量旋转
- 正交位移
-
Esc 取消变换
-
Enter 确认变换
-
Delete 删除图案
接下来,我们说一下具体的代码实现。
1.在ImgControler 对象中声明相关属性。
import { Vector2 } from '../math/Vector2'
import { Object2D, Object2DType } from '../objects/Object2D'
import { Img } from '../objects/Img'
import { Matrix3 } from '../math/Matrix3'
import { Scene } from '../core/Scene'
import { ImgTransformer,ImgData } from './ImgTransformer'
import { MouseShape } from './MouseShape'
import { Frame, State } from './Frame'
// change 事件
const _changeEvent = { type: 'change' }
/* 变换之前的暂存数据类型 */
type TransformStage = {
clipCenter: Vector2
clipOpposite: Vector2
parentPvmInvert: Matrix3
}
class ImgControler extends Object2D {
//……
// 控制状态
_controlState: State = null
// alt 键是否按下
_altKey = false
// shift 键是否按下
shiftKey = false
// 图案在父级坐标系内的变换基点
origin = new Vector2()
// 鼠标在图案父级坐标系内的坐标位
parentMousePos = new Vector2()
// 选中图案时的暂存数据,用于取消变换
controlStage: ImgData = {
position: new Vector2(),
scale: new Vector2(1, 1),
rotate: 0,
offset: new Vector2(),
}
// 变换前的暂存数据,用于设置变换基点,将裁剪坐标转图案父级坐标
transformStage: TransformStage = {
clipCenter: new Vector2(),
clipOpposite: new Vector2(),
parentPvmInvert: new Matrix3(),
}
// 图案变换器
imgTransformer = new ImgTransformer({
mousePos: this.parentMousePos,
orign: this.origin,
})
get controlState() {
return this._controlState
}
set controlState(val) {
if (this._controlState === val) {
return
}
this._controlState = val
}
get altKey() {
return this._altKey
}
set altKey(val) {
if (this._altKey === val) {
return
}
this._altKey = val
}
……
}
export { ImgControler }
在上面的代码里,我们用get,set 监听controlState和altKey的变化,之后我们会在其变化时,做一些事情。
2.当选中图案时:
- 把图案传递给imgTransformer。
- 把图案的变换信息存储到controlState中,以便按下Esc时取消变换。
get img() {
return this._img
}
set img(val) {
if (this._img === val) {
return
}
this._img = val
if (val) {
this.imgTransformer.setOption({ img: val })
this.imgTransformer.passImgDataTo(this.controlStage)
this.frame.img = val
this.dispatchEvent({ type: 'selected', img: val })
} else {
this.mouseState = null
this.controlState = null
}
this.dispatchEvent(_changeEvent)
}
3.当鼠标按下时:
- 记录图案控制状态 controlState。
- 更新鼠标在图案父级坐标系中的位置 parentMousePos。
/* 鼠标按下 */
pointerdown(img: Img | null, mp: Vector2) {
if (!this.mouseState) {
this.img = img
if (!img) {
return
}
}
// 更新鼠标裁剪位
this.clipMousePos.copy(mp)
// 获取鼠标状态
this.mouseState = this.frame.getMouseState(mp)
if (this.mouseState) {
// 控制状态等于鼠标状态
this.controlState = this.mouseState
// 更新鼠标父级位
this.updateParentMousePos()
}
this.dispatchEvent(_changeEvent)
}
/* 更新鼠标在图案父级坐标系中的位置 */
updateParentMousePos() {
const {
clipMousePos,
parentMousePos,
transformStage: { parentPvmInvert },
} = this
parentMousePos.copy(clipMousePos.clone().applyMatrix3(parentPvmInvert))
}
controlState 和mouseState 是不一样的,比如mouseState 有状态时,controlState可能没有状态。
updateParentMousePos() 方法里的parentPvmInvert 会在控制状态发生变化时,同步更新。
4.当控制状态发生改变时:
- 暂存变换数据。
- 根据控制状态更新变换基点 origin。
- 根据变换基点,偏移图案。
get controlState() {
return this._controlState
}
set controlState(val) {
if (this._controlState === val) {
return
}
this._controlState = val
const { img } = this
if (!val || !img) {
return
}
// 暂存变换数据
this.saveTransformData(img)
if (val === 'move') {
return
}
// 设置变换基点
if (val === 'rotate') {
this.setRotateOrigin()
} else if (val?.includes('scale')) {
this.setScaleOrigin()
}
// 在不改变图案世界位的前提下,基于变换基点,偏移图案
this.offsetImgByOrigin(img)
}
我们具体看一下其中的方法。
暂存变换数据:
- clipCenter 图案在裁剪坐标系中的中点。
- clipOpposite 裁剪坐标系里的对点,作为默认缩放的基点。
- parentPvmInvert 图案父级pvm矩阵的逆矩阵,用于将裁剪坐标位转图案父级坐标位。
- 把图案变换信息和鼠标位置更新到imgTransformer中。
/* 暂存变换数据 */
saveTransformData(img: Img) {
const {
clipMousePos,
imgTransformer,
frame,
transformStage: { clipCenter, clipOpposite, parentPvmInvert },
} = this
const { parent } = img
parent && parentPvmInvert.copy(parent.pvmMatrix.invert())
clipCenter.copy(frame.center)
clipOpposite.copy(frame.opposite)
imgTransformer.setOption({
img,
mouseStart: clipMousePos.clone().applyMatrix3(parentPvmInvert),
})
}
根据控制状态更新变换基点 origin。
- 缩放:默认基点在对面,按住alt键时,在图案中心。
- 旋转:基点在图案中心。
/* 设置旋转基点 */
setRotateOrigin() {
const {
origin,
imgTransformer,
clipOrigin,
transformStage: { clipCenter, parentPvmInvert },
} = this
// 图案基点在裁剪坐标系中的位置
clipOrigin.copy(clipCenter)
// 将图案中心点从裁剪坐标系转父级坐标系
origin.copy(clipCenter.clone().applyMatrix3(parentPvmInvert))
// 更新父级坐标系里基点到鼠标起点的向量
imgTransformer.updateOriginToMouseStart()
}
/* 设置缩放基点 */
setScaleOrigin() {
const {
altKey,
origin,
imgTransformer,
clipOrigin,
transformStage: { clipCenter, clipOpposite, parentPvmInvert },
} = this
// 根据altKey,将图案中心点或对点从裁剪坐标系转图案父级坐标系
if (altKey) {
clipOrigin.copy(clipCenter)
origin.copy(clipCenter.clone().applyMatrix3(parentPvmInvert))
} else {
clipOrigin.copy(clipOpposite)
origin.copy(clipOpposite.clone().applyMatrix3(parentPvmInvert))
}
// 更新父级坐标系里基点到鼠标起点的向量
imgTransformer.updateOriginToMouseStart()
}
根据变换基点,偏移图案。
offsetImgByOrigin(img: Img) {
const { offset, position, scale, rotate, pvmMatrix } = img
// 偏移量
const curOffset = new Vector2().subVectors(
offset,
this.clipOrigin.clone().applyMatrix3(pvmMatrix.invert())
)
// 当前偏移和原有偏移的向量差
const diff = new Vector2().subVectors(curOffset, offset)
// 图案的offset需要基于curOffset 做反向偏移
offset.copy(curOffset)
// 上一级的position 再偏移回来,以确保图案的世界位不变
position.sub(diff.multiply(scale).rotate(rotate))
}
我们要基于某个基点变换图案,需要通过offset 偏移图案,与此同时,为了保持图案在世界坐标系内不变,要通过position 把图案移动回去。
详细解释一下这个逻辑。
当img的offset为(0,0)时,那图案的变换基点就在图案的左上角。
若此时,我想在不改变图案在世界坐标系内的样子的前提下,把图案的变换基点设置为图案中心。
那我就要让图案的offset往左上方偏移图案尺寸的一半,并且让图案的position往右下方偏移图案尺寸的一半。
5.鼠标移动时:
- 更新鼠标在图案父级坐标系中的位置
- 变换图案
pointermove(mp: Vector2) {
if (!this.img) {
return
}
// 更新鼠标世界位
this.clipMousePos.copy(mp)
if (this.controlState) {
// 更新鼠标在图案父级坐标系中的位置
this.updateParentMousePos()
// 变换图案
this.transformImg()
} else {
// 获取鼠标状态
this.mouseState = this.frame.getMouseState(mp)
}
this.dispatchEvent(_changeEvent)
}
/* 变换图案 */
transformImg() {
const { imgTransformer, controlState, shiftKey, img } = this
controlState && imgTransformer[controlState + Number(shiftKey)]()
this.dispatchEvent({ type: 'transformed', img })
}
controlState + Number(shiftKey) 是在拼装imgTransformer中的变换方法,比如scale0、scale1等。
6.鼠标抬起时,取消控制状态controlState。
/* 鼠标抬起 */
pointerup() {
if (this.controlState) {
this.controlState = null
this.dispatchEvent(_changeEvent)
}
}
在ImgControler.vue 中的鼠标抬起事件执行相应方法。
window.addEventListener('pointerup', (event: PointerEvent) => {
switch (event.button) {
case 0:
imgControler.pointerup()
break
case 1:
orbitControler.pointerup()
break
}
})
6.在ImgControler 对象中添加一个键盘按下方法,获取键盘事件中传入shiftKey和altKey。
/* 键盘按下 */
keydown(key: string, altKey: boolean, shiftKey: boolean) {
this.altKey = altKey
this.dispatchEvent(_changeEvent)
}
当altKey 发生变化时,会被赋值器监听到,然后将缩放基点放在图案中心。
get altKey() {
return this._altKey
}
set altKey(val) {
if (this._altKey === val) {
return
}
this._altKey = val
const { img, controlState, origin, imgTransformer } = this
if (!img) {
return
}
if (controlState?.includes('scale')) {
// 将图案回退到变换之前的状态
imgTransformer.restoreImg()
// 缩放基点在图案中心
this.setScaleOrigin()
// 根据变换基点,偏移图案
this.offsetImgByOrigin(img)
// 变换图案
this.transformImg()
}
}
imgTransformer.restoreImg() 是将图案回退到变换之前的状态,这是为了在缩放的过程中改变基点时,不受图案变换后的数据影响。可以理解为清理变换数据,重新变换。
在ImgTransformer 对象中添加一个restoreImg()方法。
/* 将图案回退到变换之前的状态 */
restoreImg() {
this.copyImgData(this)
}
// 将obj中的变换数据拷贝到img中
copyImgData(obj: ImgData) {
const { position, scale, rotate, offset } = obj
const { img } = this
img.position.copy(position)
img.scale.copy(scale)
img.rotate = rotate
img.offset.copy(offset)
}
copyImgData() 之后还会用于拷贝其它ImgData类型的对象里变换数据。
后面的setScaleOrigin(),offsetImgByOrigin(),transformImg() 方法,我们之前都说过,不再解释。
6.当按下shift 键时,更新变换方式。
- 等比缩放
- 等量旋转
- 正交位移
keydown(key: string, altKey: boolean, shiftKey: boolean) {
this.shiftKey = shiftKey
this.altKey = altKey
this.dispatchEvent(_changeEvent)
}
在这里直接更新shiftKey 即可,鼠标移动时,会将controlState和shiftKey拼成变换方法。
至于具体的变换方法,我们之后会在ImgTransformer 对象里写。
7.在键盘抬起时,也要更新shiftKey和altKey
keyup(altKey: boolean, shiftKey: boolean) {
this.shiftKey = shiftKey
this.altKey = altKey
this.dispatchEvent(_changeEvent)
}
8.Esc 取消变换
keydown(key: string, altKey: boolean, shiftKey: boolean) {
this.shiftKey = shiftKey
this.altKey = altKey
if (this.img) {
switch (key) {
case 'Escape':
// 将选中图案时存储的图案变换数据controlStage 拷贝到图案中
this.imgTransformer.copyImgData(this.controlStage)
// 图案置空
this.img = null
break
}
}
this.dispatchEvent(_changeEvent)
}
在ImgTransformer 对象中添加一个copyImgData 方法:
// 将obj中的变换数据拷贝到img中
copyImgData(obj: ImgData) {
const { position, scale, rotate, offset } = obj
const { img } = this
img.position.copy(position)
img.scale.copy(scale)
img.rotate = rotate
img.offset.copy(offset)
}
9.Enter 确认变换
keydown(key: string, altKey: boolean, shiftKey: boolean) {
this.shiftKey = shiftKey
this.altKey = altKey
if (this.img) {
switch (key) {
case 'Escape':
// 将选中图案时存储的图案变换数据controlStage 拷贝到图案中
this.imgTransformer.copyImgData(this.controlStage)
// 图案置空
this.img = null
break
case 'Enter':
// 图案置空
this.img = null
break
}
}
this.dispatchEvent(_changeEvent)
}
将当前选择的图案置空即可。
10.Delete 删除图案
keydown(key: string, altKey: boolean, shiftKey: boolean) {
this.shiftKey = shiftKey
this.altKey = altKey
if (this.img) {
switch (key) {
case 'Escape':
// 将选中图案时存储的图案变换数据controlStage 拷贝到图案中
this.imgTransformer.copyImgData(this.controlStage)
// 图案置空
this.img = null
break
case 'Enter':
// 图案置空
this.img = null
break
case 'Delete':
// 将img从其所在的group中删除
this.img.remove()
// 图案置空
this.img = null
break
}
}
this.dispatchEvent(_changeEvent)
}
11.在OrbitControler.vue 中监听键盘按下和抬起事件。
/* 键盘按下 */
window.addEventListener(
'keydown',
({ key, altKey, shiftKey }: KeyboardEvent) => {
imgControler.keydown(key, altKey, shiftKey)
updateMouseCursor()
}
)
/* 键盘抬起 */
window.addEventListener('keyup', ({ altKey, shiftKey }: KeyboardEvent) => {
imgControler.keyup(altKey, shiftKey)
})
updateMouseCursor() 会在鼠标取消或确认变换时,将鼠标cursor 设置为默认状态。
关于ImgControler中操控图案的方法就是这样,接下来我们再说一下ImgTransformer中具体的变换方法。
6-4-ImgTransformer 中的变换方法
之前我们已经建立了ImgTransformer对象,只不过变换方法还空着,接下来我们就一一填充一下。
ImgTransformer对象里的变换都是在图案的父级坐标系里实现的,所以我接下来说变换的时候就不再注明坐标系了。
1.双向缩放。
缩放值=变换前的初始缩放*图案本地坐标系内(基点到鼠标的向量/基点到鼠标起点的向量)
scale0() {
const { img, scale } = this
img.scale.copy(scale.clone().multiply(this.getLocalScale()))
}
/* 获取图案本地的缩放量 */
getLocalScale() {
const { img, orign, originToMouseStart, mousePos } = this
const rotateInvert = -img.rotate
return mousePos
.clone()
.sub(orign)
.rotate(rotateInvert)
.divide(originToMouseStart.clone().rotate(rotateInvert))
}
效果如下:
rotate(-img.rotate) 就是把缩放量从图案的父级坐标系下沉到图案的本地坐标系。其原理很简单,举个例子解释一下:
当一个正方体旋转了a°后,点P的x,y值就不会相等,若我们基于此值缩放正方体,就会让正方体失真,比如变成长方体。
所以我们要让点P旋转-a°,这样算出的缩放量便不会失真。
2.双向等比缩放。
缩放值=变换前的初始缩放 * 图案本地坐标系中x,y向缩放量的平均值
scale1() {
const { img, scale } = this
const s = this.getLocalScale()
img.scale.copy(scale.clone().multiplyScalar((s.x + s.y) / 2))
}
3.单向缩放,将之前的自由缩放变成单方向的缩放即可。
scaleX0() {
this.doScaleSigleDir('x')
}
scaleY0() {
this.doScaleSigleDir('y')
}
doScaleSigleDir(dir: 'x' | 'y') {
const { img, scale } = this
const s = this.getLocalScale()
img.scale[dir] = scale[dir] * s[dir]
}
4.单向等比缩放,将之前的双向等比缩放变成基于单方向缩放量的等比缩放即可。
scaleX1() {
this.doUniformScaleSigleDir('x')
}
scaleY1() {
this.doUniformScaleSigleDir('y')
}
doUniformScaleSigleDir(dir: 'x' | 'y') {
const { img, scale } = this
const s = this.getLocalScale()
img.scale.copy(scale.clone().multiplyScalar(s[dir]))
}
5.自由旋转。
旋转值=变换前的初始旋转 + 基点到鼠标的向量与基点到鼠标起点的向量的夹角
rotate0() {
const { img, rotate, orign, originToMouseStart, mousePos } = this
img.rotate =
rotate +
mousePos.clone().sub(orign).angle() -
originToMouseStart.angle()
}
6.等量旋转。
计算出正常的旋转值ang后,计算ang包含了多少个uniformRotateAng,然后用此个数再乘以uniformRotateAng作为旋转量。
rotate1() {
const {
img,
rotate,
orign,
originToMouseStart,
mousePos,
uniformRotateAng,
} = this
const ang =
mousePos.clone().sub(orign).angle() - originToMouseStart.angle()
img.rotate =
rotate +
Math.floor((ang + uniformRotateAng / 2) / uniformRotateAng) *
uniformRotateAng
}
7.自由位移。
move0() {
const { img, position, mouseStart, mousePos } = this
img.position.copy(position.clone().add(mousePos.clone().sub(mouseStart)))
}
8.正交位移
// 正交位移
move1() {
//作业,留给同学们实现。
}
大家可以让图案在某一个坐标系里做水平或垂直移动,做完后可以发我微信(1051904257)。
总结
到目前为止图案变换的基本功能已经实现了。
图案的缩放量是在图案的本地坐标系里取的。
图案的旋转量和位移量是在图案的父级坐标系里取的。
至于为何要这么做,大家可以自己思考一下。若不确定,就写写试试。
图案的基点变换是借助了图案的offset 偏移值和position 位移实现的。
图案的控制框是把图案的节点从最底层的offset坐标系上浮到裁剪坐标系实现的。
其实利用这种矩阵变换的原理,也可以实现对任意图形的变换。
下节课,我们就说一下如何变换任意图形。