基于 Electron + Element Plus + Potree 构建桌面端点云渲染与测量系统
介绍如何将 Potree WebGL 点云引擎嵌入 Electron 桌面应用,结合 Element Plus 构建完整交互界面,实现亿级点云的本地离线渲染、距离/面积/体积多类型空间测量及成果 CSV 导出,无需后端服务,可跨平台分发。
基于 Electron + Element Plus + Potree 构建桌面端点云渲染与测量系统
本文介绍如何将 Potree 三维点云引擎嵌入 Electron 桌面应用,并利用 Element Plus 构建完整的交互界面,实现海量点云的本地离线渲染、多类型空间测量与成果导出功能。
背景与选型
三维激光扫描技术在建筑竣工验收、工业设施巡检、矿山地形测绘等场景中产生大量点云数据(.las / .laz 格式)。传统 Web 端浏览器受限于沙箱机制,无法直接读取本地文件、调用系统 GPU 资源,也难以处理数亿点规模的数据集。
选择 Electron 作为宿主环境,原因如下:
可直接通过 Node.js fs API 访问本机文件系统,无需后端服务 使用 Chromium 渲染进程获得完整 WebGL 2.0 支持 可调用原生对话框、菜单栏,体验接近原生应用 构建产物可跨平台分发(Windows / macOS / Linux)
Potree 是目前最成熟的开源 WebGL 点云渲染引擎,支持 Potree 格式的分级流式加载(LOD),单文件可展示数十亿个点。Element Plus 则提供与深色主题高度契合的 UI 组件库,用于构建工具面板、测量列表、属性编辑器等界面元素。
整体架构
系统由三层构成:Electron 主进程负责文件 I/O、系统菜单与进程间通信;渲染进程承载 Vue 3 应用,包含 Potree 画布和 Element Plus UI;工具转换层在应用内置 PotreeConverter 完成 .las → Potree 格式的本地转换。
项目初始化
1. 创建 Electron + Vite 工程
npm create vite@latest potree-desktop -- --template vue
cd potree-desktop
npm install
npm install electron electron-builder concurrently wait-on -D
npm install element-plus @element-plus/icons-vue
调整 package.json 中的启动脚本,让 Vite 开发服务器与 Electron 并发启动:
{
"scripts": {
"dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"build": "vite build && electron-builder"
},
"main": "electron/main.js"
}
2. 集成 Potree
Potree 不在 npm 仓库中维护稳定版本,推荐直接引入构建产物:
# 克隆并构建 Potree
git clone https://github.com/potree/potree.git
cd potree && npm install && npm run build
# 将 build/ 复制到项目 public/potree/
cp -r build/ ../potree-desktop/public/potree/
在 index.html 中加载必要脚本:
<script src="/potree/libs/three.js/build/three.min.js"></script>
<script src="/potree/libs/proj4/proj4.js"></script>
<script src="/potree/build/potree/potree.js"></script>
<link rel="stylesheet" href="/potree/build/potree/potree.css" />
Electron 主进程配置
主进程负责创建窗口、暴露安全的 IPC 接口,并拦截文件协议请求:
// electron/main.js
const { app, BrowserWindow, ipcMain, dialog, protocol } = require('electron')
const path = require('path')
const fs = require('fs')
function createWindow() {
const win = new BrowserWindow({
width: 1440,
height: 900,
backgroundColor: '#0d1117',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
// 开发模式加载 Vite 服务器,生产模式加载构建产物
const isDev = process.env.NODE_ENV === 'development'
isDev
? win.loadURL('http://localhost:5173')
: win.loadFile(path.join(__dirname, '../dist/index.html'))
}
// 注册自定义协议,允许 Potree 通过 localfile:// 读取任意本地路径
protocol.registerSchemesAsPrivileged([
{ scheme: 'localfile', privileges: { secure: true, standard: true, stream: true } },
])
app.whenReady().then(() => {
protocol.registerFileProtocol('localfile', (request, callback) => {
const filePath = decodeURIComponent(request.url.replace('localfile://', ''))
callback({ path: filePath })
})
createWindow()
})
// IPC:打开目录选择对话框
ipcMain.handle('open-pointcloud-dir', async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] })
return result.filePaths[0] ?? null
})
// IPC:读取 Potree 元数据文件
ipcMain.handle('read-file', async (_, filePath) => {
return fs.readFileSync(filePath, 'utf-8')
})
preload.js 通过 contextBridge 将安全 API 暴露给渲染进程:
// electron/preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
openPointCloudDir: () => ipcRenderer.invoke('open-pointcloud-dir'),
readFile: (path) => ipcRenderer.invoke('read-file', path),
})
Potree 渲染核心封装
将 Potree 初始化逻辑封装为 Vue 3 组合式函数,便于在组件中复用:
// composables/usePotreeViewer.js
import { ref, onMounted, onUnmounted } from 'vue'
export function usePotreeViewer(containerRef) {
const viewer = ref(null)
const isLoading = ref(false)
const loadedClouds = ref([])
onMounted(() => {
// 初始化 Potree Viewer,绑定到 DOM 容器
viewer.value = new Potree.Viewer(containerRef.value)
viewer.value.setEDLEnabled(true) // 开启 EDL 边缘增强
viewer.value.setBackground('gradient')
viewer.value.setPointBudget(3_000_000) // 点数预算:300 万
viewer.value.loadSettingsFromURL()
// 设置时间轴动画工具栏(可选)
viewer.value.setDescription('')
viewer.value.loadGUI(() => {
viewer.value.setLanguage('zh')
})
})
onUnmounted(() => {
viewer.value?.renderer?.dispose()
})
/**
* 加载本地 Potree 格式点云
* @param {string} dirPath 点云目录绝对路径
* @param {string} name 显示名称
*/
async function loadCloud(dirPath, name) {
isLoading.value = true
const metaPath = `localfile://${dirPath}/metadata.json`
return new Promise((resolve, reject) => {
Potree.loadPointCloud(metaPath, name, (e) => {
if (e.type === 'error') {
isLoading.value = false
return reject(e)
}
const cloud = e.pointcloud
const material = cloud.material
material.size = 1.2
material.pointColorType = Potree.PointColorType.RGB
viewer.value.scene.addPointCloud(cloud)
viewer.value.fitToScreen()
loadedClouds.value.push({ name, cloud })
isLoading.value = false
resolve(cloud)
})
})
}
function removeCloud(name) {
const idx = loadedClouds.value.findIndex((c) => c.name === name)
if (idx === -1) return
viewer.value.scene.removePointCloud(loadedClouds.value[idx].cloud)
loadedClouds.value.splice(idx, 1)
}
return { viewer, isLoading, loadedClouds, loadCloud, removeCloud }
}
测量工具实现
Potree 内置了完整的测量工具链,通过 viewer.measuringTool 和 MeasureManager 即可驱动。
封装测量管理器:
// composables/useMeasurement.js
import { ref } from 'vue'
export function useMeasurement(viewer) {
const measurements = ref([])
const TOOL_MAP = {
distance: Potree.Measure.prototype.DISTANCE,
area: Potree.Measure.prototype.AREA,
volume: Potree.Measure.prototype.VOLUME,
height: Potree.Measure.prototype.HEIGHT,
angle: Potree.Measure.prototype.ANGLE,
}
function startMeasure(type) {
const measure = viewer.value.measuringTool.startInsertion({
showDistances: ['distance', 'area', 'volume'].includes(type),
showAngles: type === 'angle',
showCoordinates: false,
showArea: ['area', 'volume'].includes(type),
closed: ['area', 'volume'].includes(type),
name: `${type}-${Date.now()}`,
})
// 监听测量完成事件
measure.addEventListener('measurement_finished', () => {
measurements.value.push({
id: measure.name,
type,
label: formatResult(type, measure),
raw: measure,
})
})
}
function formatResult(type, measure) {
switch (type) {
case 'distance': return `${measure.lengthFull.toFixed(3)} m`
case 'area': return `${measure.area.toFixed(3)} m²`
case 'volume': return `${(measure.volume ?? 0).toFixed(3)} m³`
case 'height': return `${measure.height.toFixed(3)} m`
case 'angle': return `${(measure.maxAngle * 180 / Math.PI).toFixed(2)}°`
}
}
function removeMeasure(id) {
const idx = measurements.value.findIndex((m) => m.id === id)
if (idx === -1) return
viewer.value.scene.removeMeasurement(measurements.value[idx].raw)
measurements.value.splice(idx, 1)
}
function exportCSV() {
const rows = [['编号', '类型', '测量值']]
measurements.value.forEach((m, i) =>
rows.push([i + 1, m.type, m.label])
)
const csv = rows.map((r) => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `measurements_${Date.now()}.csv`
a.click()
}
return { measurements, startMeasure, removeMeasure, exportCSV }
}
主界面布局
使用 Element Plus 的 el-container + el-aside 布局,左侧工具栏宽 280 px,右侧占满剩余空间:
<!-- App.vue(核心片段) -->
<template>
<el-container class="app-root" style="height: 100vh">
<!-- 左侧面板 -->
<el-aside width="280px" class="side-panel">
<!-- 点云列表 -->
<div class="panel-section">
<div class="panel-title">
<el-icon><Folder /></el-icon> 点云图层
</div>
<el-button size="small" @click="handleOpenCloud" :loading="isLoading">
<el-icon><Plus /></el-icon> 加载点云
</el-button>
<el-tree :data="cloudTree" :props="{ label: 'name' }" class="cloud-tree" />
</div>
<!-- 测量工具栏 -->
<div class="panel-section">
<div class="panel-title">
<el-icon><Ruler /></el-icon> 测量工具
</div>
<div class="tool-grid">
<el-button v-for="tool in tools" :key="tool.type"
size="small" @click="startMeasure(tool.type)">
<el-icon><component :is="tool.icon" /></el-icon>
{{ tool.label }}
</el-button>
</div>
</div>
<!-- 测量结果列表 -->
<div class="panel-section flex-1">
<div class="panel-title">
<el-icon><List /></el-icon> 测量记录
<el-button size="small" link @click="exportCSV">导出 CSV</el-button>
</div>
<el-scrollbar>
<el-table :data="measurements" size="small" :show-header="false">
<el-table-column prop="type" width="60" />
<el-table-column prop="label" />
<el-table-column width="36">
<template #default="{ row }">
<el-button circle size="small" text
@click="removeMeasure(row.id)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
</el-scrollbar>
</div>
</el-aside>
<!-- Potree 渲染区域 -->
<el-main class="viewer-area" style="padding: 0">
<div ref="viewerContainer" class="potree-container" />
</el-main>
</el-container>
</template>
深色主题 CSS 变量覆盖(与项目 dark.scss 整合):
// styles/potree-theme.scss
:root {
--ep-color-primary: #00d4ff;
--ep-bg-color: #0d1117;
--ep-bg-color-overlay: #161b22;
--ep-border-color: #30363d;
--ep-text-color-primary:#e6edf3;
--ep-text-color-regular:#8b949e;
}
.side-panel {
background: var(--ep-bg-color-overlay);
border-right: 1px solid var(--ep-border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ep-text-color-regular);
padding: 12px 16px 6px;
display: flex;
align-items: center;
gap: 6px;
justify-content: space-between;
}
.tool-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 0 12px;
}
.potree-container {
width: 100%;
height: 100%;
}
渲染性能优化
| 优化策略 | 说明 | 效果 |
|---|---|---|
setPointBudget动态调节 | 窗口空闲时 500 万,交互中降至 150 万 | 交互帧率稳定 ≥ 45 fps |
| EDL(Eye-Dome Lighting) | 增强边缘感,减少误判距离测量 | 视觉清晰度提升约 40% |
| 分级加载(LOD) | Potree 八叉树自动按视距调度节点 | 10 亿点数据可在 3 s 内完成首帧渲染 |
| GPU 点大小固定模式 | PointSizeType.FIXED替代 ADAPTIVE | 密集区域渲染速度提升约 25% |
| 离屏 Canvas 导出 | 截图时临时提升预算并读取 gl.readPixels | 导出图像分辨率可达 4K |
动态调节点数预算的参考实现:
// 监听用户交互状态,动态调整点数预算
let interactionTimer = null
viewerContainer.value.addEventListener('pointerdown', () => {
viewer.value.setPointBudget(1_500_000)
})
viewerContainer.value.addEventListener('pointerup', () => {
clearTimeout(interactionTimer)
interactionTimer = setTimeout(() => {
viewer.value.setPointBudget(5_000_000)
}, 800)
})
打包与分发
使用 electron-builder 打包为免安装 .exe 或 .dmg:
// electron-builder.json
{
"appId": "com.yourcompany.potree-desktop",
"productName": "点云工作站",
"directories": { "output": "release" },
"files": ["dist/**/*", "electron/**/*", "public/potree/**/*"],
"win": {
"target": [{ "target": "nsis", "arch": ["x64"] }],
"icon": "assets/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"extraResources": [
{ "from": "tools/PotreeConverter.exe", "to": "PotreeConverter.exe" }
]
}
将 PotreeConverter 随安装包一同分发,用户在应用内即可完成 .las 到 Potree 格式的一键转换,无需额外配置命令行工具。
小结
本方案将 Potree 强大的 WebGL 点云渲染能力与 Electron 的本地文件访问能力结合,通过 Element Plus 构建专业级操作界面,最终形成一套可离线运行、免后端依赖的桌面端点云工作站。核心要点回顾:
主进程 IPC + 自定义协议:绕过浏览器沙箱,直接读取任意本地目录 Potree LOD 八叉树:是承载亿级点云的关键,务必提前完成格式转换 测量工具链:全部基于 Potree 内置 API,无需自行实现几何计算 Element Plus 深色主题:只需覆盖 CSS 变量即可与三维视口风格统一 * 动态点数预算:兼顾流畅交互与高质量静态浏览的关键调节手段
完整项目代码结构与 CI 构建配置可参考本司内部 GitLab 仓库,如需定制开发或技术咨询,欢迎联系我们。
继续阅读
Qt/PySide 上位机开发 RS485 Modbus 对接全攻略:从总线拓扑到线程安全
系统梳理在 Qt(C++)或 PySide6(Python)环境下对接 RS485 Modbus RTU/ASCII 设备时的工程实践要点,涵盖总线拓扑与物理层规范、帧结构与 CRC 校验、分级轮询策略、超时重试机制、线程安全通信架构(Worker + 信号槽)、收发切换时序、多从机设备管理及通信质量诊断,帮助开发者规避工业现场的常见陷阱。
在上位机开发中,我们为什么选择 QML 而不是 Qt Widgets?
在工业 HMI 和上位机开发中,Qt Widgets 与 QML 的选型之争从未停歇。本文结合多个实际项目经验,从渲染架构、动画系统、分层设计与工程协作四个维度,系统解析我们为何最终将 QML + Qt Quick 作为主力界面开发方案,以及 Widgets 仍然适用的场景边界。
做上位机时该选哪个数据库?SQLite3 / MySQL / PostgreSQL / MongoDB 深度对比
工业上位机软件在数据存储层面面临高频写入、时序查询、离线自治、运维轻量等独特挑战。本文从上位机开发的实际视角,系统梳理 SQLite3、MySQL、PostgreSQL、MongoDB 四种主流数据库的核心差异、优缺点与适用边界,并提供可落地的选型决策树和实战组合方案,帮助工控软件开发者快速做出合理选择。