工业软件2025/10/2513 分钟阅读

基于 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 格式的本地转换。

graph TD A[Electron 主进程<br/>Main Process] -->|IPC Bridge| B[渲染进程<br/>Renderer Process] A --> C[PotreeConverter<br/>本地 CLI 工具] C -->|转换输出| D[(Potree 格式<br/>本地文件系统)] B --> E[Vue 3 应用层] E --> F[Potree Viewer<br/>WebGL 渲染画布] E --> G[Element Plus UI<br/>工具面板 / 属性栏] F -->|读取| D G -->|控制测量工具| F

项目初始化

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.measuringToolMeasureManager 即可驱动。

flowchart LR U([用户点击\n工具按钮]) --> D{工具类型} D -->|距离| M1[startMeasurement\nDistance] D -->|面积| M2[startMeasurement\nArea] D -->|体积| M3[startMeasurement\nVolume] D -->|高程| M4[startMeasurement\nHeight] D -->|角度| M5[startMeasurement\nAngle] M1 & M2 & M3 & M4 & M5 --> R[Potree MeasureManager\n实时计算] R --> T[结果写入\nElement Plus 列表] T --> E[导出 CSV / JSON]

封装测量管理器:

// 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 仓库,如需定制开发或技术咨询,欢迎联系我们。

本文作者
成都尘轻扬技术团队

尘轻扬科技团队长期服务于制造业、军工和高可靠场景,聚焦 AI 私有知识库、工业软件与现场控制系统交付。

联系作者团队
RELATED

继续阅读

查看全部文章
工业软件24 分钟阅读

Qt/PySide 上位机开发 RS485 Modbus 对接全攻略:从总线拓扑到线程安全

系统梳理在 Qt(C++)或 PySide6(Python)环境下对接 RS485 Modbus RTU/ASCII 设备时的工程实践要点,涵盖总线拓扑与物理层规范、帧结构与 CRC 校验、分级轮询策略、超时重试机制、线程安全通信架构(Worker + 信号槽)、收发切换时序、多从机设备管理及通信质量诊断,帮助开发者规避工业现场的常见陷阱。

成都尘轻扬技术团队
上位机 / HMI18 分钟阅读

在上位机开发中,我们为什么选择 QML 而不是 Qt Widgets?

在工业 HMI 和上位机开发中,Qt Widgets 与 QML 的选型之争从未停歇。本文结合多个实际项目经验,从渲染架构、动画系统、分层设计与工程协作四个维度,系统解析我们为何最终将 QML + Qt Quick 作为主力界面开发方案,以及 Widgets 仍然适用的场景边界。

成都尘轻扬技术团队
工业软件27 分钟阅读

做上位机时该选哪个数据库?SQLite3 / MySQL / PostgreSQL / MongoDB 深度对比

工业上位机软件在数据存储层面面临高频写入、时序查询、离线自治、运维轻量等独特挑战。本文从上位机开发的实际视角,系统梳理 SQLite3、MySQL、PostgreSQL、MongoDB 四种主流数据库的核心差异、优缺点与适用边界,并提供可落地的选型决策树和实战组合方案,帮助工控软件开发者快速做出合理选择。

成都尘轻扬技术团队