使用 UniApp 在 Canvas 中的绘制

1. 项目背景

在 UniApp 中,我们可以利用 canvas 组件实现各种绘图功能。本篇文章介绍如何在 UniApp 的 Canvas 画布中绘制图形、图片,并支持撤销、清空、数据存储等功能。此外,我们会探讨如何将绘制的墙体转换到 3D 视图进行渲染。

2. 关键功能实现

2.1 页面结构

首先,我们在 template 结构中定义一个 canvas 画布用于绘图,并提供多个按钮来切换模式和执行操作。

ini 复制代码
<template>
	<view class="wrapper">
		<view class="container">
			<canvas canvas-id="myCanvas" id="myCanvas" class="canvas"
				@touchstart="handleTouchStart"
				@touchmove="handleTouchMove"
				@touchend="handleTouchEnd"
				@click="handleClick"></canvas>
		</view>
		<view class="actions">
			<button @click="undo">撤销</button>
			<button @click="clearCanvas">清空</button>
			<button @click="submit">三维</button>
			<button @click="render">render</button>
			<button @click="setMode('wall')">墙体</button>
			<button @click="setMode('door')">门</button>
		</view>
	</view>
</template>

2.2 数据状态管理

data 中定义状态变量,用于存储绘制的墙体、门等数据。

javascript 复制代码
export default {
	data() {
		return {
			lines: [], // 存储墙体线条
			doors: [], // 存储门
			mode: 'wall', // 当前绘制模式
			isDrawing: false,
			hasFirstLine: false,
			ctx: null,
			windowWidth: 0,
			windowHeight: 0
		};
	},
	mounted() {
		// 初始化 canvas 画布
		uni.getSystemInfo({
			success: (res) => {
				this.windowWidth = res.windowWidth;
				this.windowHeight = res.windowHeight;
			}
		});
		setTimeout(() => {
			this.ctx = uni.createCanvasContext('myCanvas');
			this.clearCanvas();
		}, 200);
	}
}

2.3 绘制墙体

handleTouchStarthandleTouchMove 方法中,实现墙体的绘制。

kotlin 复制代码
handleTouchStart(e) {
	if (this.mode !== 'wall') return;
	const touch = e.touches[0];
	if (this.hasFirstLine) {
		const lastLine = this.lines[this.lines.length - 1];
		const dx = touch.x - lastLine.x2;
		const dy = touch.y - lastLine.y2;
		if (Math.sqrt(dx * dx + dy * dy) > 10) {
			this.isDrawing = false;
			return;
		}
	}
	this.lines.push({ x1: touch.x, y1: touch.y, x2: touch.x, y2: touch.y });
	this.isDrawing = true;
}

handleTouchMove(e) {
	if (!this.isDrawing || this.mode !== 'wall') return;
	const touch = e.touches[0];
	const lastLine = this.lines[this.lines.length - 1];
	lastLine.x2 = touch.x;
	lastLine.y2 = touch.y;
	this.drawAll();
}

2.5 绘制门

门的绘制依赖于墙体,点击墙体上的某点即可添加一扇门。

ini 复制代码
handleClick(e) {
	if (this.mode !== 'door') return;
	const x = e.detail.x;
	const y = e.detail.y;
	const wall = this.findWallUnderTouch(x, y);
	if (!wall) return;

	const doorLength = 50;
	const dx = wall.x2 - wall.x1;
	const dy = wall.y2 - wall.y1;
	const length = Math.sqrt(dx * dx + dy * dy);
	if (length < doorLength) return;

	const ux = dx / length;
	const uy = dy / length;
	const door = {
		x1: x - (doorLength / 2) * ux,
		y1: y - (doorLength / 2) * uy,
		x2: x + (doorLength / 2) * ux,
		y2: y + (doorLength / 2) * uy
	};
	this.doors.push(door);
	this.drawAll();
}

2.6 绘制所有元素

kotlin 复制代码
drawAll() {
	if (!this.ctx) return;
	this.ctx.setFillStyle('#f8f8f8');
	this.ctx.fillRect(0, 0, this.windowWidth, this.windowHeight);

	this.lines.forEach(line => {
		this.ctx.setStrokeStyle('#000');
		this.ctx.moveTo(line.x1, line.y1);
		this.ctx.lineTo(line.x2, line.y2);
		this.ctx.stroke();
	});

	this.doors.forEach(door => {
		this.ctx.setFillStyle('#00FF00');
		this.ctx.fillRect(door.x1, door.y1, door.x2 - door.x1, 5);
	});

	this.ctx.draw();
}

3.全部代码:

ini 复制代码
<template>
	<view class="wrapper">
		<view class="container">
			<canvas canvas-id="myCanvas" id="myCanvas" class="canvas" @touchstart="handleTouchStart"
				@touchmove="handleTouchMove" @touchend="handleTouchEnd" @click="handleClick"></canvas>
		</view>
		<view class="actions">
			<button @click="undo" type="default" size="mini">撤销</button>
			<button @click="clearCanvas" type="warn" size="mini">清空</button>
			<button @click="submit" type="primary" size="mini">三维</button>
			<button @click="render" type="primary" size="mini">render</button>
			<button @click="setMode('wall')" type="primary" size="mini">墙体</button>
			<button @click="setMode('corpse')" type="primary" size="mini">尸体</button>
			<button @click="setMode('door')" type="primary" size="mini">门</button>
		</view>
	</view>
</template>

<script>
	const wallThickness = 10;
	const normalFillColor = "#D3D3D3";
	const normalBorderColor = "#5A5A5A";
	const activeFillColor = "#ADD8E6";
	const activeBorderColor = "#0000FF";
	export default {
		data() {
			return {
				lines: [], // 存储所有的线条
				corpses: [], // 存储尸体位置
				doors: [],
				isDrawing: false,
				hasFirstLine: false,
				pointRadius: 10, // 小蓝点的触摸范围
				ctx: null,
				windowWidth: 0,
				windowHeight: 0,
				mode: 'wall', // 'wall' 表示绘制墙体, 'corpse' 表示添加尸体
				showLength: false, // 是否显示长度
				currentDoor: null, // 当前正在绘制的门
			};
		},
		mounted() {
			uni.getSystemInfo({
				success: (res) => {
					this.windowWidth = res.windowWidth;
					this.windowHeight = res.windowHeight;
				},
			});

			setTimeout(() => {
				this.ctx = uni.createCanvasContext('myCanvas');
				this.clearCanvas();
			}, 200);
		},
		methods: {
			setMode(mode) {
				this.mode = mode;
			},
			clearCanvas() {
				if (!this.ctx) return;
				this.ctx.setFillStyle('#f8f8f8');
				this.ctx.fillRect(0, 0, this.windowWidth, this.windowHeight);
				this.ctx.draw();
				this.lines = [];
				this.corpses = [];
				this.doors = []
				this.hasFirstLine = false;
				const storedLines = uni.getStorageSync('lines');
				console.log('storedLines:', storedLines)
				uni.setStorageSync('lines', []);
				uni.setStorageSync('corpses', []);
				uni.setStorageSync('doors', []);
			},
			findWallUnderTouch(x, y) {
				const threshold = 10; // 允许的误差范围(像素)
				return this.lines.find(wall => {
					const distance = this.pointToSegmentDistance(x, y, wall.x1, wall.y1, wall.x2, wall.y2);
					return distance <= threshold;
				});
			},
			pointToSegmentDistance(px, py, x1, y1, x2, y2) {
				// 计算墙体的方向向量
				const dx = x2 - x1;
				const dy = y2 - y1;

				// 计算墙体的长度
				const lengthSquared = dx * dx + dy * dy;
				if (lengthSquared === 0) return Math.hypot(px - x1, py - y1);

				// 计算点在线段上的投影比例(t)
				let t = ((px - x1) * dx + (py - y1) * dy) / lengthSquared;
				t = Math.max(0, Math.min(1, t));

				// 计算投影点坐标
				const projX = x1 + t * dx;
				const projY = y1 + t * dy;

				// 计算点击点到投影点的距离
				return Math.hypot(px - projX, py - projY);
			},
			handleClick(e) {
				if (this.mode !== 'door' || !this.ctx) return;

				const x = e.detail.x;
				const y = e.detail.y;
				const wall = this.findWallUnderTouch(x, y);
				if (!wall) return; // 必须点击在墙体上

				const doorLength = 50; // 门的固定长度
				const dx = wall.x2 - wall.x1;
				const dy = wall.y2 - wall.y1;
				const wallLength = Math.sqrt(dx * dx + dy * dy);

				if (wallLength < doorLength) return; // 如果墙体太短,不绘制门

				// 计算墙体单位方向向量
				const ux = dx / wallLength;
				const uy = dy / wallLength;

				// 计算门的中心点(用户点击的位置)
				let doorCenterX = x;
				let doorCenterY = y;

				// 计算门的起点和终点
				let doorX1 = doorCenterX - (doorLength / 2) * ux;
				let doorY1 = doorCenterY - (doorLength / 2) * uy;
				let doorX2 = doorCenterX + (doorLength / 2) * ux;
				let doorY2 = doorCenterY + (doorLength / 2) * uy;

				const door = {
					x1: doorX1,
					y1: doorY1,
					x2: doorX2,
					y2: doorY2,
					width: 5
				};
				this.doors.push(door);
				this.drawAll();
				uni.setStorageSync('doors', this.doors);
			},
			undo() {
				if (this.mode === 'wall' && this.lines.length > 0) {
					this.lines.pop();
					this.hasFirstLine = this.lines.length > 0;
					this.drawAll();
					uni.setStorageSync('lines', this.lines);
				} else if (this.mode === 'corpse' && this.corpses.length > 0) {
					this.corpses.pop();
					this.drawAll();
					uni.setStorageSync('corpses', this.corpses);
				} else if (this.mode === 'door' && this.doors.length > 0) {
					this.doors.pop();
					this.drawAll();
					uni.setStorageSync('doors', this.doors);
				}
			},
			handleTouchStart(e) {
				if (!this.ctx) return;
				const touch = e.touches[0];
				if (this.mode === 'wall') {
					if (this.hasFirstLine) {
						const lastLine = this.lines[this.lines.length - 1];
						const dx = touch.x - lastLine.x2;
						const dy = touch.y - lastLine.y2;
						const distance = Math.sqrt(dx * dx + dy * dy);
						if (distance > this.pointRadius) {
							this.isDrawing = false;
							return;
						}
					}
					this.lines.push({
						x1: touch.x,
						y1: touch.y,
						x2: touch.x,
						y2: touch.y
					});
					this.isDrawing = true;
				} else if (this.mode === 'corpse') {
					this.corpses.push({
						x: touch.x,
						y: touch.y
					});
					this.drawAll();
					uni.setStorageSync('corpses', this.corpses);
				}

			},
			handleTouchMove(e) {
				if (!this.isDrawing || !this.ctx || this.mode !== 'wall') return;
				const touch = e.touches[0];
				const lastLine = this.lines[this.lines.length - 1];
				lastLine.x2 = touch.x; //实时更新最后一根线的位置
				lastLine.y2 = touch.y;

				// 计算像素长度
				const dx = lastLine.x2 - lastLine.x1;
				const dy = lastLine.y2 - lastLine.y1;
				const pixelLength = Math.sqrt(dx * dx + dy * dy);

				// 换算为 1:50 比例后的毫米长度
				const mmPerPixel = 25.4 / 480; // 1 像素的毫米值
				const scaleFactor = 1 * 50; // 1:50 缩放比例
				this.lastLineLengthMM = (pixelLength * mmPerPixel * scaleFactor).toFixed(2);
				// 触摸移动时显示长度
				this.showLength = true;
				this.drawAll(); // 触发绘制
			},
			handleTouchEnd() {

				if (!this.isDrawing || !this.ctx || this.mode !== 'wall') return;
				this.isDrawing = false;
				this.hasFirstLine = true;
				// 触摸结束后隐藏长度
				this.showLength = false;
				this.drawAll(); // 重新绘制,让最后一根墙体恢复普通颜色
				uni.setStorageSync('lines', this.lines);

			},
			drawAll() {
				if (!this.ctx) return;
				this.ctx.setFillStyle('#f8f8f8');
				this.ctx.fillRect(0, 0, this.windowWidth, this.windowHeight);

				this.lines.forEach((line, index) => {
					const dx = line.x2 - line.x1;
					const dy = line.y2 - line.y1;
					const length = Math.sqrt(dx * dx + dy * dy);

					const offsetX = (dy / length) * wallThickness;
					const offsetY = -(dx / length) * wallThickness;

					const points = [{
							x: line.x1 - offsetX,
							y: line.y1 - offsetY
						},
						{
							x: line.x1 + offsetX,
							y: line.y1 + offsetY
						},
						{
							x: line.x2 + offsetX,
							y: line.y2 + offsetY
						},
						{
							x: line.x2 - offsetX,
							y: line.y2 - offsetY
						},
					];

					const isLastLine = index === this.lines.length - 1 && this.isDrawing;
					const fillColor = isLastLine ? activeFillColor : normalFillColor;
					const borderColor = isLastLine ? activeBorderColor : normalBorderColor;

					this.ctx.setFillStyle(fillColor);
					this.ctx.beginPath();
					this.ctx.moveTo(points[0].x, points[0].y);
					this.ctx.lineTo(points[1].x, points[1].y);
					this.ctx.lineTo(points[2].x, points[2].y);
					this.ctx.lineTo(points[3].x, points[3].y);
					this.ctx.closePath();
					this.ctx.fill();

					this.ctx.setStrokeStyle(borderColor);
					this.ctx.setLineWidth(2);
					this.ctx.beginPath();
					this.ctx.moveTo(points[0].x, points[0].y);
					this.ctx.lineTo(points[1].x, points[1].y);
					this.ctx.lineTo(points[2].x, points[2].y);
					this.ctx.lineTo(points[3].x, points[3].y);
					this.ctx.closePath();
					this.ctx.stroke();

					// 画最后一根线的蓝点
					if (index === this.lines.length - 1) {
						this.ctx.setFillStyle('#0000FF');
						this.ctx.beginPath();
						this.ctx.arc(line.x2, line.y2, 5, 0, 2 * Math.PI);
						this.ctx.fill();

						// 只有在 touchmove 时才显示长度文本
						if (this.showLength) {
							const textX = (line.x1 + line.x2) / 2;
							const textY = (line.y1 + line.y2) / 2 - 20;
							this.ctx.setFillStyle('#000000');
							this.ctx.setFontSize(14);
							this.ctx.fillText(this.lastLineLengthMM + ' mm', textX, textY);
						}
					}
				});

				// 绘制门(矩形)
				this.ctx.setFillStyle('#ff0000');
				this.doors.forEach(door => {
					const dx = door.x2 - door.x1;
					const dy = door.y2 - door.y1;
					const length = Math.sqrt(dx * dx + dy * dy);
					if (length === 0) return;

					// 计算墙体的法向量,保证门的宽度与墙体一致
					const ux = dx / length;
					const uy = dy / length;
					const nx = -uy * door.width / 2;
					const ny = ux * door.width / 2;

					// 计算矩形门的四个角
					const p1 = {
						x: door.x1 + nx,
						y: door.y1 + ny
					};
					const p2 = {
						x: door.x2 + nx,
						y: door.y2 + ny
					};
					const p3 = {
						x: door.x2 - nx,
						y: door.y2 - ny
					};
					const p4 = {
						x: door.x1 - nx,
						y: door.y1 - ny
					};

					// 绘制门
					this.ctx.beginPath();
					this.ctx.moveTo(p1.x, p1.y);
					this.ctx.lineTo(p2.x, p2.y);
					this.ctx.lineTo(p3.x, p3.y);
					this.ctx.lineTo(p4.x, p4.y);
					this.ctx.closePath();
					this.ctx.fill();
				});

				this.ctx.draw(true);
			},
			getLineIntersection(x1, y1, x2, y2, x3, y3, x4, y4) {
			    const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
			
			    // 如果分母为 0,说明两条线平行或重合,无交点
			    if (denominator === 0) return null;
			
			    const intersectX = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denominator;
			    const intersectY = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denominator;
			
			    // 检查交点是否在线段范围内
			    if (
			        intersectX >= Math.min(x1, x2) && intersectX <= Math.max(x1, x2) &&
			        intersectY >= Math.min(y1, y2) && intersectY <= Math.max(y1, y2) &&
			        intersectX >= Math.min(x3, x4) && intersectX <= Math.max(x3, x4) &&
			        intersectY >= Math.min(y3, y4) && intersectY <= Math.max(y3, y4)
			    ) {
			        return { x: intersectX, y: intersectY };
			    }
			    return null; // 交点不在两条线段的范围内
			},

			submit() {
				uni.navigateTo({
					url: '/pages/index/index'
				});
			},
			render() {
				uni.navigateTo({
					url: '/pages/render/render'
				});
			}
		}
	};
</script>


<style>
	.container {
		display: flex;
		justify-content: center;
		align-items: center;
		height: calc(100vh - 100px);
	}

	.canvas {
		width: 100%;
		height: 100%;
		border: 1px solid #ddd;
	}

	.actions {
		display: flex;
	}

	.actions button {
		flex: 1;
	}
</style>
相关推荐
weixin_443566987 分钟前
CSS 预处理器
前端·css
excel16 分钟前
webpack 核心编译器 第一节
前端
大怪v27 分钟前
前端佬们!塌房了!用过Element-Plus的进来~
前端·javascript·element
拉不动的猪38 分钟前
electron的主进程与渲染进程之间的通信
前端·javascript·面试
软件技术NINI1 小时前
html css 网页制作成品——HTML+CSS非遗文化扎染网页设计(5页)附源码
前端·css·html
fangcaojushi1 小时前
npm常用的命令
前端·npm·node.js
阿丽塔~1 小时前
新手小白 react-useEffect 使用场景
前端·react.js·前端框架
鱼樱前端2 小时前
Rollup 在前端工程化中的核心应用解析-重新认识下Rollup
前端·javascript
m0_740154672 小时前
SpringMVC 请求和响应
java·服务器·前端
加减法原则2 小时前
探索 RAG(检索增强生成)
前端