ImgControler图案控制器

342 阅读27分钟

前言

源码

github.com/buglas/canv…

学习目标

  • 创建ImgControler对象
  • 使用ImgControler对象变换图案

知识点

  • 图案选择
  • 图案控制框
  • 鼠标状态与样式
  • 图案变换

前情回顾

之前我们用OrbitControler 对象实现了相机的变换,接下来我们建立ImgControler对象。

image-20230301224856781

1-ImgControler对象的功能分析

ImgControler对象具备以下功能:

1.在选中的图案上显示控制框。

image-20230328172752649

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>

效果如下:

image-20230328195816107

当鼠标点击图案时,就会打印出相应图案的名称。

selectObj()方法是在裁剪坐标系中实现的,因此需要把鼠标的client位置转裁剪位,把图案路径从本地偏移坐标系转到裁剪坐标系绘制,然后在用ctx.isPointInPath() 判断鼠标点是否在图形中。

接下来我们把图案的控制框显示出来。

3-图案控制框

image-20230328200823263

图案控制框处于渲染最顶层,由线框、矩形节点和基点组成。

因为我们在变换图案时,用的是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是鼠标在裁剪坐标系中的位置。

现在运行项目,当鼠标放在图案控制器框的不同位置时,便会打印出相应的状态。

image-20230330155057670

接下来,我们在鼠标状态发生改变时,改变鼠标样式。

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 顶点索引与控制框的节点对应关系如下:

frame

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)
}

现在当我们把鼠标放图案控制框上,就可以看到相应的鼠标图案:

image-20230401105612047

不过默认鼠标还在,需要将其隐藏。

在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

image-20230401121621239

  • scaleX

image-20230401121642063

  • scaleY

image-20230401121752378

  • move

image-20230401121658683

  • rotate

image-20230401121713959

关于鼠标变换样式就说到这,接下来我们开始变换图案。

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的具体操作步骤如下:

  1. 当选中图案时:

    • 把图案传递给imgTransformer。
    • 把图案的变换信息存储到controlState中,以便按下Esc时取消变换。
  2. 当鼠标按下时:

    • 记录图案控制状态。
    • 更新鼠标在图案父级坐标系里的坐标位。
  3. 当控制状态controlState 发生改变时:

    • 暂存变换数据

      • clipCenter 图案在裁剪坐标系中的中点。
      • clipOpposite 裁剪坐标系里的对点,作为默认缩放的基点。
      • parentPvmInvert 图案父级pvm矩阵的逆矩阵,用于将裁剪坐标位转图案父级坐标位。
      • 把图案变换信息和鼠标位置更新到imgTransformer中。
    • 根据控制状态更新图案变换基点 origin。

      • 缩放:默认基点在对面,按住alt键时,在图案中心。
      • 旋转:基点在图案中心。
    • 根据变换基点,偏移图案。

  4. 鼠标移动时:

    • 更新鼠标在图案父级坐标系中的位置
    • 变换图案
  5. 鼠标抬起时,取消控制状态controlState

  6. 当按下alt键时,缩放基点在图案中心。

  7. 当按下shift 键时,更新变换方式。

    • 等比缩放
    • 等量旋转
    • 正交位移
  8. Esc 取消变换

  9. Enter 确认变换

  10. 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))
}

效果如下:

image-20230405111645258

rotate(-img.rotate) 就是把缩放量从图案的父级坐标系下沉到图案的本地坐标系。其原理很简单,举个例子解释一下:

image-20230611165823831

当一个正方体旋转了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坐标系上浮到裁剪坐标系实现的。

其实利用这种矩阵变换的原理,也可以实现对任意图形的变换。

下节课,我们就说一下如何变换任意图形。