使用 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_382395231 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__1 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.2 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~3 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华3 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子6 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅6 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni6 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学8 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端