canvas实战案例-T恤图案编辑器

1,546 阅读12分钟

源码

github.com/buglas/canv…

学习目标

  • 创建T恤图案编辑器

知识点

  • TransformControler 的应用
  • canvas 全局合成
  • canvas 图层控制
  • DOM和图形组件的数据传递

1-搭建前端静态

1-1-页面结构

T恤图案编辑器的页面结构如下图所示。

image-20230301170310805

  • 图案库:存储图案素材;向T恤图案编辑器添加图案。
  • T恤图案编辑器:管理和变换编辑器中的图案。
  • 效果图:在实际产品中实时显示图案效果。
  • 图层:管理图案,比如选择图案、删除图案、图案排序等。

1-2-创建项目

按照之前说过的vue3+vitest+ts初始化图形项目的方法,创建一个tshirt-editor项目,将之前的lmm文件导入其中。具体流程我不再多说,大家可以看源码。

在App.vue页中把基本的页面结构搭出来。

  • /src/App.vue
<script setup lang="ts"></script>

<template>
    <div id="left">
        <div class="tit">图案库</div>
        <div id="patternLib">
            <div class="patternWrapper">
                <img
                    class="pattern"
                    src="https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/1.png"
                    alt=""
                />
            </div>
        </div>
    </div>
    <div id="center" ref="editorDomRef"></div>
    <div id="right">
        <div id="effect" ref="effectDomRef"></div>
        <ul id="layers">
            <li class="layer">
                <img
                    class="thumbnail"
                    src="https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/1.png"
                    alt=""
                />
                01
            </li>
        </ul>
    </div>
</template>

<style scoped>
* {
    box-sizing: border-box;
}

#left {
    display: flex;
    flex-direction: column;
    border-right: 1px solid #ddd;
}
.tit {
    font-weight: bold;
    font-size: 18px;
    padding: 15px 15px 6px 15px;
    border-bottom: 1px solid #ddd;
}

#patternLib {
    padding: 15px;
    width: 200px;
    height: 100%;
    overflow-y: scroll;
}
.patternWrapper {
    flex-wrap: wrap;
    margin: 15px 0px;
}
.pattern {
    width: 100%;
    cursor: pointer;
    transition: transform 100ms;
}
.pattern:hover {
    transform: scale(1.1);
}

#center {
    flex: 1;
    background-image: url('https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/alpha_back.png');
    position: relative;
}
.tips {
    background-color: rgba(0, 0, 0, 0.6);
    color: #fff;
    padding: 15px;
    line-height: 28px;
    position: absolute;
    bottom: 0;
}

#right {
    display: flex;
    flex-direction: column;
    width: 350px;
    border-left: 1px solid #ddd;
}
#effect {
    background-color: antiquewhite;
    height: 350px;
}
#layers {
    flex: 1;
    padding: 0;
    margin: 0;
    overflow-y: scroll;
}
.layer {
    display: flex;
    align-items: center;
    padding: 3px;
    list-style: none;
    cursor: pointer;
    transition: all 100ms;
    border-bottom: 1px solid #ddd;
    line-height: 0;
}

.layerActive {
    background-color: #f5f5f5;
    box-shadow: inset #ccc 0 1px 2px;
}
.thumbnail {
    width: 50px;
    background-image: url('https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/alpha_back.png');
    background-size: 8px;
    margin-right: 12px;
}

/* 滚动条样式 */
ul::-webkit-scrollbar,
div::-webkit-scrollbar {
    width: 8px;
}
ul::-webkit-scrollbar-thumb,
div::-webkit-scrollbar-thumb {
    -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
ul::-webkit-scrollbar-track,
div::-webkit-scrollbar-track {
    -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
</style>

效果如下:

image-20230411094953395

现在基本页面结构已经有了,接下来我们需要先用假数据模拟数据和DOM的动态交互。

2-图案库

1.模拟fetch 请求后端图案库数据

const patternData = new Promise<string[]>((resolve) => {
    const arr: string[] = []
    for (let i = 1; i <= 8; i++) {
        arr.push(
            `https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/${i}.png`
        )
    }
    resolve(arr)
})

2.将请求到的图案库绑定到相应的DOM上。

<script setup lang="ts">
import { onMounted, ref } from 'vue'

// 模拟fetch 请求后端图案库数据
const patternData = new Promise<string[]>((resolve) => {
    const arr: string[] = []
    for (let i = 1; i <= 8; i++) {
        arr.push(
            `https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/${i}.png`
        )
    }
    resolve(arr)
})

/* 图案库 */
const patternsRef = ref<string[]>([])
/* 图案库 */
patternData.then((data) => {
    patternsRef.value = data
})
/* 当点击图像库中的图案时,将图案添加到图案编辑器和图层中 */
function addPattern({ target }: MouseEvent) {}
</script>

<template>
    <div id="left">
        <div class="tit">图案库</div>
        <div id="patternLib">
            <div class="patternWrapper" v-for="item in patternsRef">
                <img class="pattern" :src="item" alt="" @click="addPattern" />
            </div>
        </div>
    </div>
    ……
</template>

效果如下:

image-20230412130254287

当点击图像库中的图案时,会将图案添加到图案编辑器和图层中。

所以我们接下来先去建立一个图案编辑器对象。

3-Editor图案编辑器

1.Editor对象会具备之前ImgControler.vue 的功能。

  • /src/component/Editor.ts
import { ref } from 'vue'
import { TransformControler } from '../lmm/controler/TransformControler'
import { OrbitControler } from '../lmm/controler/OrbitControler'
import { Scene } from '../lmm/core/Scene'
import { Group } from '../lmm/objects/Group'
import { Img2D } from '../lmm/objects/Img2D'
import { Vector2 } from '../lmm/math/Vector2'
import { EventDispatcher } from '../lmm/core/EventDispatcher'
import { SelectObj } from '../lmm/objects/ObjectUtils'
import { Object2D } from '../lmm/objects/Object2D'

class Editor extends EventDispatcher {
    /* 编辑器场景 */
    editorScene = new Scene()
    /* 编辑器中的图案 */
    group = new Group()
    /* 图案控制器 */
    transformControler = new TransformControler()
    /* 相机轨道控制器 */
    orbitControler = new OrbitControler(this.editorScene.camera)
    /* 鼠标划入的图形 */
    objHover: Object2D | null = null
    /* 鼠标状态 */
    cursor = ref('default')

    constructor() {
        super()
        const { editorScene, orbitControler, group, transformControler } = this
        /* 编辑器场景*/
        editorScene.add(group, transformControler)

        /* 渲染编辑器和虚拟场景 */
        transformControler.addEventListener('change', () => {
            this.render()
        })
        orbitControler.addEventListener('change', () => {
            this.render()
        })
    }

    onMounted(editorDom: HTMLDivElement) {
        const {
            editorScene: { canvas },
        } = this

        /* 编辑器 */
        editorDom.append(canvas)
        const { clientWidth: dx, clientHeight: dy } = editorDom
        canvas.width = dx
        canvas.height = dy

        /* 编辑器事件监听 */
        canvas.addEventListener('pointerdown', this.pointerdown.bind(this))
        canvas.addEventListener('pointermove', this.pointermove.bind(this))
        window.addEventListener('pointerup', this.pointerup.bind(this))
        window.addEventListener('keydown', this.keydown.bind(this))
        window.addEventListener('keyup', this.keyup.bind(this))
        canvas.addEventListener('wheel', this.wheel.bind(this))
        canvas.addEventListener('contextmenu', this.contextmenu.bind(this))
    }

    onUnmounted() {
        const {
            editorScene: { canvas },
        } = this

        /* 删除canvas,避免onMounted时重复添加 */
        canvas.remove()

        /* 取消事件监听 */
        canvas.removeEventListener('pointerdown', this.pointerdown)
        canvas.removeEventListener('pointermove', this.pointermove)
        window.removeEventListener('pointerup', this.pointerup)
        window.removeEventListener('keydown', this.keydown)
        window.removeEventListener('keyup', this.keyup)
        canvas.removeEventListener('wheel', this.wheel)
        canvas.removeEventListener('contextmenu', this.contextmenu)
    }

    /* 鼠标按下 */
    pointerdown(event: PointerEvent) {
        const { editorScene, transformControler, group, orbitControler } = this
        const { button, clientX, clientY } = event
        const mp = editorScene.clientToClip(clientX, clientY)
        switch (button) {
            case 0:
                this.objHover = SelectObj(editorScene)(group.children, mp)
                transformControler.pointerdown(this.objHover, mp)
                this.updateMouseCursor()
                break
            case 1:
                orbitControler.pointerdown(clientX, clientY)
                break
        }
    }

    /* 鼠标移动 */
    pointermove(event: PointerEvent) {
        const { editorScene, transformControler, group, orbitControler } = this
        const { clientX, clientY } = event
        const mp = editorScene.clientToClip(clientX, clientY)
        orbitControler.pointermove(clientX, clientY)
        transformControler.pointermove(mp)
        this.objHover = SelectObj(editorScene)(group.children, mp)
        this.updateMouseCursor()
    }

    /* 鼠标抬起 */
    pointerup({ button }: PointerEvent) {
        switch (button) {
            case 0:
                this.transformControler.pointerup()
                break
            case 1:
                this.orbitControler.pointerup()
                break
        }
    }

    /* 键盘按下 */
    keydown({ key, altKey, shiftKey }: KeyboardEvent) {
        this.transformControler.keydown(key, altKey, shiftKey)
        this.updateMouseCursor()
    }

    /* 键盘抬起 */
    keyup({ altKey, shiftKey }: KeyboardEvent) {
        this.transformControler.keyup(altKey, shiftKey)
    }

    /* 滑动滚轮 */
    wheel({ deltaY }: WheelEvent) {
        this.orbitControler.doScale(deltaY)
    }

    /* 取消右键的默认功能 */
    contextmenu(event: MouseEvent) {
        event.preventDefault()
    }

    /* 更新鼠标样式 */
    updateMouseCursor() {
        const { transformControler, cursor, objHover } = this
        if (transformControler.mouseState) {
            cursor.value = 'none'
        } else if (objHover) {
            cursor.value = 'pointer'
        } else {
            cursor.value = 'default'
        }
    }

    /* 设计图和效果图的渲染 */
    render() {
        this.editorScene.render()
    }
}

export { Editor }

上面的方法都是从ImgControler.vue 中拷贝过来的。

2.在App.vue的onMounted 生命周期中调用Editor的onMounted 方法

  • /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Editor } from '../component/Editor'

……

/* 编辑器的DOMRef */
const editorDomRef = ref<HTMLDivElement>()

/* 图案编辑器 */
const editor = new Editor()

onMounted(() => {
    if (!editorDomRef.value) {
        return
    }
    
    /* 图案编辑器 */
    editor.onMounted(editorDomRef.value)
})

</script>

3.定义一个designSize 设计尺寸,用于规范所有图案的在编辑器中的尺寸。

  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……
    /* 设计尺寸 */
    designSize = 0

    onMounted(editorDom: HTMLDivElement) {
        const {
            editorScene: { canvas },
        } = this

        /* 编辑器 */
        editorDom.append(canvas)
        const { clientWidth: dx, clientHeight: dy } = editorDom
        canvas.width = dx
        canvas.height = dy

        /* 设计尺寸 */
        const designSize = Math.min(dx, dy) * 0.5
        this.designSize = designSize
        ……
    }
    ……
}

designSize在onMounted() 中会取canvas宽高最小值的一半。

4.在Editor中,建立将图案添加到图案编辑器中的方法。

  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……

    /* 添加图案 */
    addImg(image: HTMLImageElement) {
        const {
            group: { children },
        } = this

        /* 图案序号,基于最大序号递增 */
        const maxNum = Math.max(...children.map((obj) => obj.layerNum))
        const layerNum = (children.length ? maxNum : 0) + 1

        /* 建立Img2D对象 */
        const img2D = new Img2D({
            image,
            layerNum,
            name: '图层' + layerNum,
        })

        /* 基于设计尺寸设置图案尺寸 */
        this.setImg2DSize(img2D, 0.5)
        /* 添加图案 */
        this.group.add(img2D)
        /* 选择图案 */
        this.transformControler.obj = img2D
        return img2D
    }

    /* 设置图案尺寸 */
    setImg2DSize(img2D: Img2D,ratio:number) {
        const { designSize } = this
        const { width, height } = img2D.image as HTMLImageElement
        const w = designSize*ratio
        const h = (w * width) / height
        img2D.size.set(w, h)
        img2D.offset.set(-w / 2, -h / 2)
    }
}

export { Editor }

5.在App.vue中点击图案库中的图案时,调用Editor的addImg()方法,同时绑定鼠标。

  • /src/App.vue
<script setup lang="ts">
import { Editor } from '../component/Editor'

……    
    
/* 图案编辑器 */
const editor = new Editor()
const { cursor } = editor

/* 当点击图像库中的图案时,将图案添加到图案编辑器和图层中 */
function addPattern({ target }: MouseEvent) {
    if (!(target instanceof Image)) {
        return
    }
    /* 将图片添加到编辑器中 */
    editor.addImg(target)
}
</script>

<template>
    <div id="left">
        <div class="tit">图案库</div>
        <div id="patternLib">
            <div class="patternWrapper" v-for="item in patternsRef">
                <img class="pattern" :src="item" alt="" @click="addPattern" />
            </div>
        </div>
    </div>
    <div id="center" ref="editorDomRef" :style="{ cursor }"></div>
    ……
</template>

现在点击图像库中的图案,便可以将图案添加到图案编辑区中,并且可以对其进行变换。

image-20230417111025219

6.在图案编辑器中添加一张设计图,用于作为图案变换的参考。

  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……
    /* 设计图 */
    designImg = new Img2D({
        index: 1000,
    })

    constructor() {
        super()
        const { editorScene, orbitControler, group, transformControler, designImg } = this
        /* 编辑器场景*/
        editorScene.add(group, transformControler, designImg)
        ……
    }

    ……     
    
    /* 配置设计图 */
    setDesignImg(src: string) {
        const { designImg, designSize } = this
        /* 图案尺寸随设计尺寸而定,位置居中 */
        designImg.setOption({
            src,
            size: new Vector2(designSize),
            offset: new Vector2(-designSize / 2),
        })
        /* 渲染 */
        ;(designImg.image as HTMLImageElement).onload = () => {
            this.editorScene.render()
        }
    }
}

export { Editor }

在App.vue 中模拟一个请求设计图的方法。

  • /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Editor } from '../component/Editor'


/* 图案类型 */
type ImgType = {
    src?: string
    globalCompositeOperation: GlobalCompositeOperation
}
/* 模拟fetch请求后端T恤数据 */
const TShirtData = new Promise<{
    designImgSrc: string
    effectImgData: ImgType[]
}>((resolve) => {
    const path = 'https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/'
    resolve({
        /* 设计图 */
        designImgSrc: path + 'design.png',
        /* 效果图素材 */
        effectImgData: [
            {
                src: '',
                globalCompositeOperation: 'source-over',
            },
            {
                src: path + 'shirt-shadow.jpg',
                globalCompositeOperation: 'multiply',
            },
            {
                src: path + 'shirt-mask.png',
                globalCompositeOperation: 'destination-in',
            },
            {
                src: '',
                globalCompositeOperation: 'destination-in',
            },
            {
                src: path + 'shirt-origin.jpg',
                globalCompositeOperation: 'destination-over',
            },
        ],
    })
})

/* 图案编辑器 */
const editor = new Editor()
const { cursor } = editor

onMounted(() => {
    ……
    /* T恤数据 */
    TShirtData.then(({ designImgSrc }) => {
        editor.setDesignImg(designImgSrc)
    })
})
</script>

效果如下:

image-20230417133125147

之前请求数据时的effectImgData是用于显示效果图的,接下来咱们来说其实现过程。

4-Effector效果图

效果图可用于在编辑图案的同时,实时显示实际效果。

效果图的实现,可以用二维图像合成,也可以通过三维模型实现。

我们这里先用二维图像合成,三维的之后我会放到three.js 课程里说。

二维图像的合成,我是用canvas 2d中内置的合成属性globalCompositeOperation 来做的,若大家不熟悉此属性,需补一下《canvas从入门到放飞自我-基础篇》里的全局合成

4-1-T恤图案合成原理

效果图的合成需要图案和几张T恤相关的图片:

  • T恤原图

image-20231026224437126

  • T恤投影图,用于为图案添加阴影,使其更真实。

image-20231026224548207

  • T恤的图案遮罩图,对图案进行裁剪,使图案只能出现在它应该出现的地方。

image-20231026224648856

效果图的合成方式如下图所示:

image-20231025085749333

在我当前的canvas 组件中代码逻辑如下:

/* 设计尺寸 */
const size = new Vector2(400)
/* T恤偏移 */
const offset = new Vector2(-size.width / 2)

/* 文件路径 */
const path = 'https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/'

/* 图案 */
const pattern = new Img2D({
    src: path + '1.png',
    offset: new Vector2(-200, -165),
    size: new Vector2(400, 330),
})

/* TShirt 投影图 */
const shirtShadow = new Img2D({
    src: path + 'shirt-shadow.jpg',
    offset,
    size,
    style: {
        globalCompositeOperation: 'multiply',
    },
})

/* TShirt 遮罩图 */
const shirtMask = new Img2D({
    src: path + 'shirt-mask.png',
    offset,
    size,
    style: {
        globalCompositeOperation: 'destination-in',
    },
})

/* 用作裁剪的图案 */
const patternMask = new Img2D({
    image: pattern.image,
    offset: pattern.offset,
    size: pattern.size,
    style: {
        globalCompositeOperation: 'destination-in',
    },
})

/* TShirt 原图 */
const shirtOrigin = new Img2D({
    src: path + 'shirt-origin.jpg',
    offset,
    size,
    style: {
        globalCompositeOperation: 'destination-over',
    },
})

scene.add(pattern, shirtShadow, shirtMask, patternMask, shirtOrigin)

效果如下:

下载

4-2-项目里的效果图

效果图的合成需要图案编辑器里的图案,因此需要在Editor里生成一张虚拟canvas,这个canvas的尺寸要根据效果图的Dom尺寸而定。

1.在Editor中添加一个虚拟场景,使其中的图案随编辑器场景而变。

  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……
    /* 虚拟场景 */
    resultScene = new Scene()
    /* 虚拟场景里的图案集合 */
    resultGroup = new Group()

    constructor() {
        super()
        const {
            editorScene,
            orbitControler,
            group,
            transformControler,
            designImg,
            resultScene,
            resultGroup,
        } = this
        ……
        /* 虚拟场景 */
        resultScene.add(resultGroup)

        /* 虚拟场景的图案同步编辑器场景的图案 */
        // 添加图案
        group.addEventListener('add', ({ obj }) => {
            if (obj instanceof Img2D) {
                const { image, position, rotate, scale, offset, size, uuid } = obj
                resultGroup.add(
                    new Img2D({
                        image,
                        position,
                        rotate,
                        scale,
                        offset,
                        size,
                        uuid,
                    })
                )
            }
        })
        // 变换图案
        transformControler.addEventListener('transformed', ({ obj }) => {
            const { position, rotate, scale, offset } = obj
            const resultImg = resultGroup.children[group.children.indexOf(obj)]
            if (resultImg instanceof Img2D) {
                resultImg.setOption({
                    position,
                    rotate,
                    scale,
                    offset,
                })
            }
        })
        // 删除图案
        group.addEventListener('remove', ({ obj }) => {
            resultGroup.getObjectByProperty('uuid', obj.uuid)?.remove()
        })
    }

    /* 设计图和效果图的渲染 */
    render() {
        this.editorScene.render()
        this.resultScene.render()
        this.dispatchEvent({ type: 'render' })
    }

    ……
}

export { Editor }

2.虚拟场景的尺寸需要随效果图的DOM尺寸而定义。因此需要在App.vue 中将效果图的DOM尺寸传入Editor。

  • /src/App.vue
<script setup lang="ts">
……

/* 效果图的DOMRef */
const effectDomRef = ref<HTMLDivElement>()
……

onMounted(() => {
    if (!editorDomRef.value||!effectDomRef.value) {
        return
    }
    
    /* 图案编辑器 */
    editor.onMounted(editorDomRef.value,effectDomRef.value)
    ……
})
</script>

<template>
    ……
    <div id="right">
        <div id="effect" ref="effectDomRef"></div>
        ……
    </div>
</template>
  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……

    onMounted(editorDom: HTMLDivElement, effectDom: HTMLDivElement) {
        const {
            editorScene: { canvas },
            resultScene: { canvas: resultCanvas },
            resultGroup,
        } = this

        ……

        /* 虚拟场景 */
        const { clientWidth: fx, clientHeight: fy } = effectDom
        resultCanvas.width = fx
        resultCanvas.height = fy
        resultGroup.setOption({
            scale: new Vector2(fx / designSize),
            position: new Vector2(0,fx * 0.12),
        })
    }

    ……
}

export { Editor }

3.建立一个Effector对象,用于渲染效果图。

  • /src/component/Effector.ts
import { Scene } from '../lmm/core/Scene'
import { Img2D } from '../lmm/objects/Img2D'
import { Vector2 } from '../lmm/math/Vector2'
import { ImagePromise } from '../lmm/objects/ObjectUtils'

/* 图案数据类型 */
type Img2DType = {
    src?: string
    globalCompositeOperation: GlobalCompositeOperation
}

class Effector {
    scene = new Scene()

    onMounted(effectDom: HTMLDivElement) {
        const {
            scene: { canvas },
        } = this
        const { clientWidth: fx, clientHeight: fy } = effectDom
        effectDom.append(canvas)
        canvas.width = fx
        canvas.height = fy
    }

    onUnmounted() {
        this.scene.canvas.remove()
    }

    /* 添加图案 */
    addImgs(effectImg2DData: Img2DType[], resultCanvas: HTMLCanvasElement) {
        const {
            scene: {
                canvas: { width },
            },
            scene,
        } = this
        const pros: Promise<HTMLImageElement>[] = []
        effectImg2DData.forEach(({ src, globalCompositeOperation }, index) => {
            let image: HTMLImageElement | HTMLCanvasElement = new Image()
            if (src) {
                image.src = src
                pros.push(ImagePromise(image))
            } else {
                image = resultCanvas
            }
            scene.add(
                new Img2D({
                    size: new Vector2(width),
                    offset: new Vector2(-width / 2),
                    index,
                    image,
                    style: { globalCompositeOperation },
                })
            )
        })

        /* 渲染 */
        Promise.all(pros).then(() => {
            scene.render()
        })
    }

    /* 渲染 */
    render() {
        this.scene.render()
    }
}

export { Effector }

4.在App.vue 中实例化Effector对象,将效果图相关的图案数据传入Effector。

  • /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Editor } from './component/Editor'
import { Effector } from './component/Effector'

……

/* 图案类型 */
type ImgType = {
    src?: string
    globalCompositeOperation: GlobalCompositeOperation
}
/* 模拟fetch请求后端T恤数据 */
const TShirtData = new Promise<{
    designImgSrc: string
    effectImgData: ImgType[]
}>((resolve) => {
    const path = 'https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/'
    resolve({
        /* 设计图 */
        designImgSrc: path + 'design.png',
        /* 效果图素材 */
        effectImgData: [
            {
                src: '',
                globalCompositeOperation: 'source-over',
            },
            {
                src: path + 'shirt-shadow.jpg',
                globalCompositeOperation: 'multiply',
            },
            {
                src: path + 'shirt-mask.png',
                globalCompositeOperation: 'destination-in',
            },
            {
                src: '',
                globalCompositeOperation: 'destination-in',
            },
            {
                src: path + 'shirt-origin.jpg',
                globalCompositeOperation: 'destination-over',
            },
        ],
    })
})

/* 图案库 */
const patternsRef = ref<string[]>([])

/* 编辑器的DOMRef */
const editorDomRef = ref<HTMLDivElement>()

/* 效果图的DOMRef */
const effectDomRef = ref<HTMLDivElement>()

/* 图案编辑器 */
const editor = new Editor()
const { cursor } = editor

/* 效果图 */
const effector = new Effector()

/* 渲染效果图 */
editor.addEventListener('render', () => {
    effector.render()
})

onMounted(() => {
    ……
    if (!editorDomRef.value || !effectDomRef.value) {
        return
    }
    ……
    
    /* T恤数据 */
    TShirtData.then(({ designImgSrc, effectImgData }) => {
        editor.setDesignImg(designImgSrc)
        effector.addImgs(effectImgData, editor.resultScene.canvas)
    })
    
    /* 效果图 */
    effector.onMounted(effectDomRef.value)
})
    
onUnmounted(() => {
    editor.onUnmounted()
    effector.onUnmounted()
})
……
</script>

最终效果如下:

image-20230418140435946

现在图案的编辑和效果展示就已经实现了。

接下来,我们基于编辑器里的图案显示相应图层。

5-图层

图层可用于图案的选择、排序、隐藏等管理操作。

1.在添加图案时,显示相应图层。

  • /src/App.vue
<script setup lang="ts">
……
/* 图层数据类型 */
interface Layer {
    src: string
    name: string
    uuid: string
    active: boolean
}

/* 图层集合 */
const layersRef = ref<Layer[]>([])
……
/* 当点击图案库中的图案时,将图案添加到图案编辑器和图层中 */
function addPattern({ target }: MouseEvent) {
    if (!(target instanceof Image)) {
        return
    }

    /* 将图案添加到编辑器中 */
    const { uuid, name } = editor.addImg(target)

    /* 取消当前图层的选择 */
    for (let layer of layersRef.value) {
        if (layer.active) {
            layer.active = false
            break
        }
    }

    /* 添加图案到layersRef中 */
    layersRef.value.unshift({
        src: target.src,
        active: true,
        uuid,
        name,
    })
}
</script>

<template>
    ……
    <div id="right">
        <div id="effect" ref="effectDomRef"></div>
        <ul id="layers">
            <template v-for="(item, index) in layersRef" :key="item.uuid">
                <li
                    class="layer"
                    :class="{ layerActive: item.active }"
                    :data-uuid="item.uuid"
                    :index="index"
                >
                    <img class="thumbnail" :src="item.src" alt="" />
                    {{ item.name }}
                </li>
            </template>
        </ul>
    </div>
</template>

效果如下:

image-20230419081112049

2.点击图层选择图案。

在Editor 中添加一个基于uuid 选择图案的方法。

  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……
    /* 基于uuid 选择图案 */
    selectImgByUUID(uuid: string) {
        const { group, transformControler } = this
        const obj = group.getObjectByProperty('uuid', uuid)
        if (obj instanceof Img2D) {
            transformControler.obj = obj
        }
    }
}

export { Editor }

在图层上添加选择事件,选择图层所对应的图案。

  • /src/App.vue
<script setup lang="ts">
……
/* 选择图层 */
function selectLayer(index: number) {
    const { value } = layersRef
    // 激活图层
    activateLayer(index)
    // 更新图案的控制状态
    editor.selectImgByUUID(value[index].uuid)
}

/* 激活图层 */
function activateLayer(index: number) {
    const { value } = layersRef
    for (let i = 0, len = value.length; i < len; i++) {
        value[i].active = i === index
    }
}
</script>

<template>
    ……
    <div id="right">
        <div id="effect" ref="effectDomRef"></div>
        <ul id="layers">
            <template v-for="(item, index) in layersRef" :key="item.uuid">
                <li
                    class="layer"
                    :class="{ layerActive: item.active }"
                    :data-uuid="item.uuid"
                    :index="index"
                    @click="selectLayer(index)"
                >
                    <img class="thumbnail" :src="item.src" alt="" />
                    {{ item.name }}
                </li>
            </template>
        </ul>
    </div>
</template>

效果如下:

image-20230419094129145

3.通过拖拽图层控制图案的排序。

在Editor 中添加一个基于图案索引置换图案的方法。

  • /src/component/Editor.ts
class Editor extends EventDispatcher {
    ……

    /* 基于图案索引置换图案 */
    replaceImg(a: number, b: number) {
        const { group, resultGroup } = this
        for (let { children } of [group, resultGroup]) {
            ;[children[a], children[b]] = [children[b], children[a]]
        }
        this.render()
    }
}

export { Editor }

在图层上添加拖拽事件,置换图层和图案。

  • /src/App.vue
<script setup lang="ts">
……
/* 开始拖拽 */
function dragstart({ dataTransfer }: DragEvent, index: number) {
    dataTransfer && dataTransfer.setData('index', index.toString())
}

/* 置入 */
function drop({ dataTransfer }: DragEvent, index: number) {
    if (!dataTransfer) {
        return
    }
    /* 激活图层 */
    const dragIndex = parseInt(dataTransfer.getData('index'))
    activateLayer(dragIndex)

    const { value } = layersRef
    /* 选择图案 */
    editor.selectImgByUUID(value[dragIndex].uuid)
    /* 置换图层 */
    ;[value[dragIndex], value[index]] = [value[index], value[dragIndex]]
    /* 置换图案 */
    const len = value.length - 1
    editor.replaceImg(len - dragIndex, len - index)
}
</script>

<template>
    ……
    <div id="right">
        <div id="effect" ref="effectDomRef"></div>
        <ul id="layers">
            <template v-for="(item, index) in layersRef" :key="item.uuid">
                <li
                    class="layer"
                    :class="{ layerActive: item.active }"
                    :data-uuid="item.uuid"
                    :index="index"
                    @click="selectLayer(index)"
                    draggable="true"
                    @dragover.prevent
                    @dragstart="dragstart($event, index)"
                    @drop="drop($event, index)"
                >
                    <img class="thumbnail" :src="item.src" alt="" />
                    {{ item.name }}
                </li>
            </template>
        </ul>
    </div>
</template>

4.点击图案,选择图层。

/* 图案编辑器 */
const editor = new Editor()
const {
    cursor,
    transformControler,
    group,
    group: { children }
} = editor
transformControler.addEventListener('selected', ({ obj }) => {
    // 更新图层选择状态
    activateLayer(children.length - 1 - children.indexOf(obj))
})

5.删除图案时,删除图层。

group.addEventListener('remove', ({ obj: { uuid } }) => {
    // 删除图层
    removeLayer(uuid)
})
/* 删除图层 */
function removeLayer(uuid: string) {
    const { value } = layersRef
    for (let i = 0, len = value.length; i < len; i++) {
        if (value[i].uuid === uuid) {
            value.splice(i, 1)
            break
        }
    }
}

好啦,到目前为止,整个图案编辑器的项目就搞定了。

6.作业:在图层右侧添加一只眼睛,控制图层可见性。

layer

总结

现在我们已经完成了《canvas进阶课程-矩阵变换》。

整个课程主要告诉了大家两点知识:

  • 以面向对象的思路架构canvas。

  • 矩阵变换的应用:

    • 本地模型矩阵
    • 世界模型矩阵
    • 视图投影矩阵
    • 裁剪坐标系
    • canvas画布的坐标系
    • client坐标系
    • 基点变换

矩阵变换是图形学的基础,若大家看了这个课程,再去理解三维世界里的矩阵变换,会更加简单。

比如,三维物体的位移、旋转和缩放也是可以存储于矩阵中,位移和缩放数据可以用三维向量表示,旋转数据可以用四元数或欧拉表示。

之后的canvas 进阶课我有以下打算:

  • 基于滴滴的三个面试题,展开一篇canvas 进阶课。我因为在去滴滴的过程中,遇到了几个很经典的面试题,所以想着分享给大家,提高大家的面试通关率。
  • 引入高中物理,比如弹力、阻力、摩擦力等,为canvas动画做好铺垫。