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 绘制墙体
在 handleTouchStart
和 handleTouchMove
方法中,实现墙体的绘制。
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>