vue 一个简单的项目 之三 详情页面 step3 实现Header 渐隐渐现效果

本文介绍如何在Vue项目中实现详情页面Header的渐隐渐现效果,包括创建Header组件、设置样式、实现滚动监听及透明度渐变。

上篇,我们实现了一个公用组件画廊组件。本篇,我们将实现 详情页面 Header 的渐隐渐现效果。

首先,创建一个detail-header 分支,pull 下来,在新分支上写代码。

主要效果就是,当页面的scroll 是顶部的时候,页面的 顶部会显示返回的标志。当banner 被滑动到视口之外,就显示标准的背景色加title 的header。

首先在,pages/deteil/ components 目录下,创建文件 Header.vue ,给Header.vue 初始化,如下。

<template>
  <div>header</div>
</template>

<script>
export default {
  name: 'DetailHeader'
}
</script>

<style lang="stylus" scoped="">

</style>

在Detail.vue 中引入,如下。

<template>
  <div>
    <detail-banner></detail-banner>
    <detail-header></detail-header>
  </div>
</template>

<script>
import DetailHeader from './components/Header'
import DetailBanner from './components/Banner'
export default {
  name: 'Detail',
  components: {
    DetailBanner,
    DetailHeader
  }
}
</script>

<style lang="stylus" scoped>

</style>

好了。下面,我们做好了,header 返回首页按钮样式,如下。

<template>
  <div>
    <div class="header-abs">
      <div class="iconfont header-abs-back">&#xe624;</div>
    </div>
    <div class="header-fixed"></div>
  </div>
</template>

<script>
export default {
  name: 'DetailHeader'
}
</script>

<style lang="stylus" scoped="">
  .header-abs
    position: absolute
    left: .2rem
    top: .2rem
    width: .8rem
    height: .8rem
    text-align: center
    line-height: .8rem
    border-radius: .8rem
    background: rgba(0, 0, 0, .8)
    .header-abs-back
      color: #fff
</style>

然后,点击这个按钮,会进入首页,如下。

<template>
  <div>
    <router-link tag="div" to="/" class="header-abs">
      <div class="iconfont header-abs-back">&#xe624;</div>
    </router-link>
    <div class="header-fixed"></div>
  </div>
</template>

<script>
export default {
  name: 'DetailHeader'
}
</script>

<style lang="stylus" scoped="">
  .header-abs
    position: absolute
    left: .2rem
    top: .2rem
    width: .8rem
    height: .8rem
    text-align: center
    line-height: .8rem
    border-radius: .8rem
    background: rgba(0, 0, 0, .8)
    .header-abs-back
      color: #fff
</style>

然后,写出来正常的header 样式,如下。

<template>
  <div>
    <router-link tag="div" to="/" class="header-abs">
      <div class="iconfont header-abs-back">&#xe624;</div>
    </router-link>
    <div class="header-fixed">
      <router-link tag="div" to="/">
        <div class="iconfont header-fixed-back">&#xe624;</div>
      </router-link>
      景点详情
    </div>
  </div>
</template>

<script>
export default {
  name: 'DetailHeader'
}
</script>

<style lang="stylus" scoped="">
  @import '~styles/variables.styl'
  .header-abs
    position: absolute
    left: .2rem
    top: .2rem
    width: .8rem
    height: .8rem
    text-align: center
    line-height: .8rem
    border-radius: .8rem
    background: rgba(0, 0, 0, .8)
    .header-abs-back
      color: #fff
  .header-fixed
    position: fixed
    top: 0
    left: 0
    right: 0
    height: .86rem
    line-height: .86rem
    text-align: center
    color: #fff
    background: $bgColor
    font-size: .32rem
    .header-fixed-back
      position: absolute
      top: 0
      left: 0
      width: .64rem
      text-align: center
      font-size: .4rem
      color: #fff
</style>

好啦,下面来写业务逻辑了。

<template>
  <div>
    <router-link
      tag="div"
      to="/"
      class="header-abs"
      v-show="showAbs"
    >
      <div class="iconfont header-abs-back">&#xe624;</div>
    </router-link>
    <div class="header-fixed" v-show="!showAbs">
      <router-link tag="div" to="/">
        <div class="iconfont header-fixed-back">&#xe624;</div>
      </router-link>
      景点详情
    </div>
  </div>
</template>

<script>
export default {
  name: 'DetailHeader',
  data () {
    return {
      showAbs: true
    }
  },
  methods: {
    handleScroll () {
      const top = document.documentElement.scrollTop
      if (top > 60) {
        this.showAbs = false
      } else {
        this.showAbs = true
      }
    }
  },
  activated () {
    window.addEventListener('scroll', this.handleScroll)
  }
}
</script>

<style lang="stylus" scoped="">
  @import '~styles/variables.styl'
  .header-abs
    position: absolute
    left: .2rem
    top: .2rem
    width: .8rem
    height: .8rem
    text-align: center
    line-height: .8rem
    border-radius: .8rem
    background: rgba(0, 0, 0, .8)
    .header-abs-back
      color: #fff
  .header-fixed
    position: fixed
    top: 0
    left: 0
    right: 0
    height: .86rem
    line-height: .86rem
    text-align: center
    color: #fff
    background: $bgColor
    font-size: .32rem
    .header-fixed-back
      position: absolute
      top: 0
      left: 0
      width: .64rem
      text-align: center
      font-size: .4rem
      color: #fff
</style>

好啦,基本功能实现了,下面来实现渐隐渐现效果。

<template>
  <div>
    <router-link
      tag="div"
      to="/"
      class="header-abs"
      v-show="showAbs"
    >
      <div class="iconfont header-abs-back">&#xe624;</div>
    </router-link>
    <div
      class="header-fixed"
      v-show="!showAbs"
      :style="opacityStyle"
    >
      <router-link tag="div" to="/">
        <div class="iconfont header-fixed-back">&#xe624;</div>
      </router-link>
      景点详情
    </div>
  </div>
</template>

<script>
export default {
  name: 'DetailHeader',
  data () {
    return {
      showAbs: true,
      opacityStyle: {
        opacity: 0
      }
    }
  },
  methods: {
    handleScroll () {
      const top = document.documentElement.scrollTop
      if (top > 60) {
        let opacity = top / 140
        opacity = opacity > 1 ? 1 : opacity
        this.opacityStyle = { opacity }
        this.showAbs = false
      } else {
        this.showAbs = true
      }
    }
  },
  activated () {
    window.addEventListener('scroll', this.handleScroll)
  }
}
</script>

<style lang="stylus" scoped="">
  @import '~styles/variables.styl'
  .header-abs
    position: absolute
    left: .2rem
    top: .2rem
    width: .8rem
    height: .8rem
    text-align: center
    line-height: .8rem
    border-radius: .8rem
    background: rgba(0, 0, 0, .8)
    .header-abs-back
      color: #fff
  .header-fixed
    position: fixed
    top: 0
    left: 0
    right: 0
    height: .86rem
    line-height: .86rem
    text-align: center
    color: #fff
    background: $bgColor
    font-size: .32rem
    .header-fixed-back
      position: absolute
      top: 0
      left: 0
      width: .64rem
      text-align: center
      font-size: .4rem
      color: #fff
</style>

Done!

可以提交合并代码啦。

<think>我们被要求使用Vue3实现一个工具,将LAS数据转换为PCD格式并实现可视化展示。根据用户的需求,我们需要完成以下步骤: 1. 读取LAS文件(通常使用laspy库,但注意在浏览器环境中无法直接使用laspy,因为laspy是Python库) 2. 将LAS数据转换为PCD格式(PCD是点云数据的一种格式,通常由点坐标和颜色等属性组成) 3. 在浏览器中可视化PCD点云(可以使用Three.js等WebGL库) 然而,由于Vue3是运行在浏览器端的JavaScript框架,而laspy是Python库,因此我们不能直接在浏览器中使用。所以我们需要考虑以下方案: - 使用一个能够解析LAS文件的JavaScript库,例如:`laz-perf`、`potree-core`等,或者使用WebAssembly版本的LAS解析器。 - 或者构建一个后端服务(例如使用Python的Flask或Django)来处理LAS文件,然后前端通过API上传文件并获取转换后的PCD数据。但用户要求使用Vue3,所以这里我们可能需要前后端分离。 但用户明确要求使用Vue3编写工具,这意味着我们需要在浏览器端完成所有操作。因此,我们必须找到在浏览器中解析LAS文件的方法。 经过调研,我们可以使用`potree-core`库,它支持在浏览器中解析和显示LAS/LAZ文件。但是,用户要求转换为PCD格式并可视化,我们可以利用`potree-core`来解析LAS,然后提取点数据并转换为PCD格式,最后使用Three.js或其他库来显示。 然而,直接转换整个文件为PCD可能会遇到性能问题(大文件),所以我们需要考虑分块处理或使用Web Worker。 另一种方案是使用`las-js`(如果存在)或其他纯JavaScript的LAS解析器。但据我所知,目前没有非常成熟的纯JavaScript LAS解析器。因此,我们可能需要借助WebAssembly。 这里,我们选择使用`laz-perf`的WebAssembly版本(例如`laz-perf.js`)来解析LAS文件。但是,由于这个库的使用较为复杂,而且我们还需要将解析后的数据转换为PCD格式,然后进行可视化。 步骤: 1. 在Vue3项目中引入能够解析LAS的库(例如`laz-perf`的wasm)和Three.js(用于可视化)。 2. 上传LAS文件,使用wasm解析器读取文件,获取点云数据(坐标、颜色等)。 3. 将点云数据转换为PCD格式(文本或二进制)。PCD格式有文本格式和二进制格式,文本格式易于生成但文件较大,二进制格式紧凑但需要处理二进制数据。 4. 使用Three.js加载PCD文件并可视化。 但是,我们也可以跳过生成PCD文件的步骤,直接将解析后的点云数据用Three.js显示,这样效率更高。不过用户要求转换为PCD格式,所以我们需要生成PCD文件(可以下载或直接用于可视化)。 因此,我们可以分为两个部分: - 转换:将LAS文件转换为PCD文件(提供下载) - 可视化:在转换后直接显示点云 由于在浏览器中处理大文件可能会造成页面卡顿,我们需要考虑使用Web Worker来在后台线程进行转换。 具体实现思路: 1. 创建Vue3项目,安装必要的依赖:three, three.pcdloader(或自己实现PCDLoader),以及处理wasm的库(如laz-perf.js)。 2. 编写一个Web Worker,用于在后台解析LAS文件和生成PCD数据(文本格式)。 3. 主线程中,用户选择LAS文件后,将文件传递给Web Worker。 4. Web Worker中: a. 加载wasm解析器(例如laz-perf)。 b. 解析LAS文件,获取点云数据(坐标、颜色等)。 c. 将点云数据转换为PCD格式的字符串(文本格式)。 d. 将PCD字符串或二进制数据传回主线程。 5. 主线程收到转换完成的PCD数据后: a. 提供下载链接(如果需要保存)。 b. 使用Three.js的PCDLoader加载该数据(可以直接使用字符串,避免文件下载)并显示在场景中。 但是,由于在Web Worker中加载wasm可能会遇到路径问题,我们需要确保wasm文件被正确加载。 另外,我们也可以考虑使用`potree-core`的解析功能,但`potree-core`体积较大,可能不适合轻量级应用。 考虑到复杂性,这里我们选择使用`laser.js`(一个轻量级的LAS文件解析库,纯JavaScript),但它只支持LAS格式(不支持LAZ),且功能有限。不过,对于简单的可视化,如果文件不大,可以接受。 然而,经过搜索,我们发现一个名为`js-las`的库(https://github.com/hongfaqiu/js-las),但似乎不活跃。另一个选择是`laz-perf`的JS版本:https://github.com/connormanning/laz-perf.js 我们使用`laz-perf.js`,因为它支持LAZ压缩格式,并且性能较好(使用WebAssembly)。 由于在Vue项目中直接使用WebAssembly和Web Worker需要一些配置,下面我们逐步实现。 步骤1:创建Vue3项目并安装依赖 ```bash vue create las2pcd-viewer cd las2pcd-viewer npm install three npm install @kitware/vtk.js --save # 可选,如果我们用vtk.js来显示,但这里我们使用three # 安装laz-perf.js,但是laz-perf.js没有官方npm包,我们可以从github获取,或者使用其他方式 ``` 由于`laz-perf.js`没有npm包,我们可以手动下载它的dist文件(laz-perf.js和laz-perf.wasm)并放在public目录下,然后在代码中动态加载。 步骤2:编写Web Worker 在public目录下放置laz-perf.js和laz-perf.wasm文件,例如public/wasm/laz-perf.js和public/wasm/laz-perf.wasm。 然后,我们创建一个Web Worker文件:public/workers/las2pcd.worker.js 在这个worker中,我们将: - 导入laz-perf.js - 监听主线程发来的LAS文件数据(ArrayBuffer) - 使用laz-perf解析 - 转换为PCD文本 - 将结果发回主线程 步骤3:在Vue组件中实现文件上传、转换和可视化 由于代码较长,下面只给出关键部分的代码示例。 注意:由于Web Worker中无法直接访问window,我们需要使用importScripts来导入laz-perf.js,并且需要处理wasm文件的路径。 las2pcd.worker.js: ```javascript // 告诉laz-perf.wasm的位置 LAZPERF_WASM = &#39;wasm/laz-perf.wasm&#39;; importScripts(&#39;../wasm/laz-perf.js&#39;); // 当wasm加载完成后,我们会收到消息开始解析 lazperf.ready.then(() => { self.postMessage({ type: &#39;ready&#39; }); }); self.onmessage = function (event) { const data = event.data; if (data.file) { // data.file 是ArrayBuffer try { // 创建LAS文件解析器 const lasFile = new lazperf.LASFile(data.file); // 获取点云数据 const points = []; const count = lasFile.getPointCount(); // 获取点格式 const pointFormat = lasFile.getPointFormat(); // 判断是否有颜色 const hasColor = lasFile.hasColor(); let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (let i = 0; i < count; i++) { const point = lasFile.getPoint(i); points.push(point); if (point.x < minX) minX = point.x; if (point.y < minY) minY = point.y; if (point.z < minZ) minZ = point.z; if (point.x > maxX) maxX = point.x; if (point.y > maxY) maxY = point.y; if (point.z > maxZ) maxZ = point.z; } // 转换为PCD文本格式 // PCD头部 let pcdStr = `# .PCD v0.7 - Point Cloud Data file format VERSION 0.7 FIELDS x y z`; if (hasColor) { pcdStr += " rgb"; } pcdStr += ` SIZE 4 4 4`; if (hasColor) { pcdStr += " 4"; } pcdStr += ` TYPE F F F`; if (hasColor) { pcdStr += " F"; } pcdStr += ` COUNT 1 1 1`; if (hasColor) { pcdStr += " 1"; } pcdStr += ` WIDTH ${count} HEIGHT 1 VIEWPOINT 0 0 0 1 0 0 0 POINTS ${count} DATA ascii `; // 添加点数据 for (let i = 0; i < count; i++) { const p = points[i]; pcdStr += `${p.x} ${p.y} ${p.z}`; if (hasColor) { // 将RGB颜色转换为浮点数表示(PCD要求) // 注意:LAS中的颜色是16位整数,而PCD中的rgb是float,由三个32位整数打包成一个float32 // 但是,在PCD的ASCII格式中,我们可以直接存储一个浮点数,但通常我们使用整数表示颜色,然后转换为浮点数格式 // 这里我们采用简单的转换:将16位的颜色值归一化到0-1,然后存储为浮点数 // 但是注意,PCD的rgb字段要求是float,它实际上是将三个字节(0-255)打包成一个float,但我们这里用ASCII,所以不能打包 // 在ASCII格式中,我们可以分别存储r, g, b为三个浮点数?但PCD的rgb字段是一个浮点数(打包的),所以不适合用ASCII存储。 // 因此,我们改为存储一个浮点数,该浮点数由整数rgb打包而成:r<<16 | g<<8 | b // 但这样在可视化时,需要解包。不过,Three.js的PCDLoader支持ASCII格式,并且能够解析这种打包的rgb浮点数。 const r = Math.floor(p.red / 256); // 16位转8位(0-255) const g = Math.floor(p.green / 256); const b = Math.floor(p.blue / 256); const rgb = (r << 16) | (g << 8) | b; // 将整数rgb转换为浮点数(注意:整数直接转换为浮点数,但数值相同) // 但是,在JavaScript中,我们需要将整数转换为IEEE754浮点数,但Three.js的PCDLoader在解析ASCII时,会将这个字符串解析为浮点数,然后再解包为rgb pcdStr += ` ${rgb}`; } pcdStr += &#39;\n&#39;; } // 发送回结果 self.postMessage({ type: &#39;result&#39;, pcdStr: pcdStr, count: count, hasColor: hasColor }); } catch (e) { self.postMessage({ type: &#39;error&#39;, message: e.message }); } } }; ``` 注意:上面的转换中,颜色处理部分可能有问题,因为PCD格式的rgb字段在ASCII格式中存储为浮点数,但实际上它代表的是打包的整数。Three.js的PCDLoader在解析ASCII时,会将rgb字符串解析为浮点数,然后将其解释为一个整数(通过浮点数转换),再拆分为三个字节。但是,由于浮点数精度问题,可能会导致颜色错误。因此,更好的做法是使用PCD的二进制格式,但这里为了简单,我们使用ASCII格式。 另外,由于LAS文件可能很大,我们在循环中处理所有点可能会导致内存溢出或界面卡顿(即使是在Worker中),所以我们可以考虑分块发送回数据,但这里为了简单,我们一次性发送整个字符串。 步骤4:在Vue组件中实现Vue组件中,我们需要: - 创建Three.js场景 - 使用Web Worker处理上传的LAS文件 - 转换完成后,将PCD字符串传递给Three.js的PCDLoader(注意:PCDLoader通常用于加载文件,我们可以通过创建Blob对象来模拟文件) 组件代码(部分): ```vue <template> <div> <input type="file" @change="onFileChange" accept=".las,.laz" /> <div ref="container" style="width: 800px; height: 600px;"></div> </div> </template> <script> import * as THREE from &#39;three&#39;; import { PCDLoader } from &#39;three/addons/loaders/PCDLoader.js&#39;; import { OrbitControls } from &#39;three/addons/controls/OrbitControls.js&#39;; export default { name: &#39;LASViewer&#39;, data() { return { worker: null, scene: null, camera: null, renderer: null, controls: null }; }, mounted() { this.initScene(); // 创建Web Worker this.worker = new Worker(&#39;./workers/las2pcd.worker.js&#39;); this.worker.onmessage = this.handleWorkerMessage; }, beforeUnmount() { if (this.worker) { this.worker.terminate(); } }, methods: { initScene() { // 初始化Three.js场景 this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0xaaaaaa); this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.camera.position.z = 5; this.renderer = new THREE.WebGLRenderer(); this.renderer.setSize(800, 600); this.$refs.container.appendChild(this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; // 添加坐标轴 const axesHelper = new THREE.AxesHelper(5); this.scene.add(axesHelper); const animate = () => { requestAnimationFrame(animate); this.controls.update(); this.renderer.render(this.scene, this.camera); }; animate(); }, onFileChange(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const arrayBuffer = e.target.result; // 发送给Web Worker this.worker.postMessage({ file: arrayBuffer }, [arrayBuffer]); }; reader.readAsArrayBuffer(file); }, handleWorkerMessage(e) { if (e.data.type === &#39;ready&#39;) { console.log(&#39;Worker is ready&#39;); } else if (e.data.type === &#39;result&#39;) { const pcdStr = e.data.pcdStr; // 使用PCDLoader加载PCD字符串 const loader = new PCDLoader(); // 将字符串转换为Blob const blob = new Blob([pcdStr], { type: &#39;text/plain&#39; }); const url = URL.createObjectURL(blob); loader.load(url, (points) => { // 清除场景中之前的点云 this.scene.children.filter(child => child instanceof THREE.Points).forEach(child => this.scene.remove(child)); points.geometry.center(); // 居中 points.geometry.computeBoundingBox(); points.material.size = 0.05; this.scene.add(points); URL.revokeObjectURL(url); }, undefined, (error) => { console.error(&#39;An error happened:&#39;, error); }); } else if (e.data.type === &#39;error&#39;) { console.error(e.data.message); } } } }; </script> ``` 注意:上述代码中,我们直接将PCDLoader用于加载Blob创建的URL。这样,我们不需要将点云数据保存为实际文件,而是通过内存中的字符串生成临时文件供PCDLoader加载。 另外,由于LAS文件可能很大,转换后的PCD文本文件会更大(因为ASCII格式),因此这种方法可能只适用于较小的LAS文件。对于大文件,我们应该考虑使用二进制PCD格式,并分块处理。 此外,在Web Worker中处理大文件时,可能会造成浏览器标签页卡顿(虽然不会阻塞界面,但会占用大量CPU和内存),因此在实际应用中需要进一步优化。 总结: 我们通过Web Worker在后台使用WebAssembly解析LAS文件,然后转换为PCD的ASCII文本格式,再通过Blob和Object URL传递给Three.js的PCDLoader加载显示。 由于时间和篇幅限制,这里只提供了一个基本框架。实际应用中,还需要处理错误、进度显示、性能优化等问题。 相关问题: 1. 如何优化大文件LAS到PCD的转换性能? 2. 除了PCD格式,Three.js还支持哪些点云格式? 3. 如何在前端实现点云的可视化交互(如拾取、测量等)? 参考文献: [^1]: 关于LAS点云可视化,我们参考了laspy和open3d的使用方法,但这是在Python环境下的。 [^2]: Potree Core 2.0是一个基于Web的点云可视化库,支持LAS/LAZ格式。</think># LAS 转 PCD 格式转换与可视化工具 (Vue3 实现) 我将为您设计一个基于 Vue3 的 LAS 到 PCD 格式转换工具,并在浏览器中实现点云可视化。下面是完整的解决方案: ## 解决方案设计 ### 技术栈 - Vue3 (Composition API) - Three.js (点云渲染) - Potree.js (专业点云渲染库) - LASLoader.js (LAS 文件解析) - FileSaver.js (文件下载) ### 实现思路 1. 用户上传 LAS/LAZ 文件 2. 使用 LASLoader.js 解析文件内容 3. 将点云数据转换为 PCD 格式 4. 提供 PCD 文件下载 5. 使用 Three.js + Potree.js 实现点云可视化 ## 完整代码实现 ### 文件结构 ``` src/ ├── components/ │ ├── PointCloudViewer.vue │ └── FileConverter.vue ├── utils/ │ ├── lasParser.js │ └── pcdGenerator.js ├── App.vue └── main.js ``` ### 1. 主应用组件 (src/App.vue) ```vue <template> <div class="app-container"> <header> <h1>LAS/PCD 点云处理工具</h1> <p>上传 LAS/LAZ 文件,转换为 PCD 格式并可视化</p> </header> <main> <FileConverter @file-converted="handleFileConverted" /> <PointCloudViewer v-if="pointCloudData" :pointData="pointCloudData" /> </main> <footer> <p>基于 Vue3 和 Three.js 的点云处理工具</p> </footer> </div> </template> <script setup> import { ref } from &#39;vue&#39;; import FileConverter from &#39;./components/FileConverter.vue&#39;; import PointCloudViewer from &#39;./components/PointCloudViewer.vue&#39;; const pointCloudData = ref(null); const handleFileConverted = (data) => { pointCloudData.value = data; }; </script> <style> .app-container { font-family: &#39;Segoe UI&#39;, Tahoma, Geneva, Verdana, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; color: #333; } header { text-align: center; margin-bottom: 30px; background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); color: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); } h1 { margin: 0; font-size: 2.5rem; } footer { text-align: center; margin-top: 40px; padding: 20px; color: #777; font-size: 0.9rem; } main { display: grid; grid-template-columns: 1fr 2fr; gap: 30px; } @media (max-width: 900px) { main { grid-template-columns: 1fr; } } </style> ``` ### 2. 文件转换组件 (src/components/FileConverter.vue) ```vue <template> <div class="converter-container"> <div class="upload-area" @dragover.prevent @drop.prevent="handleDrop"> <input type="file" id="las-file" accept=".las,.laz" @change="handleFileUpload" hidden > <div v-if="!isProcessing" class="upload-content"> <svg-icon-cloud-upload class="upload-icon" /> <p>拖放 LAS/LAZ 文件到此处<br>或<button @click="triggerFileInput">选择文件</button></p> <p class="file-info" v-if="currentFile">已选择: {{ currentFile.name }}</p> </div> <div v-else class="processing"> <div class="spinner"></div> <p>处理中... {{ progress }}%</p> </div> </div> <div class="controls"> <button :disabled="!currentFile || isProcessing" @click="convertToPCD" class="convert-btn" > <svg-icon-convert /> 转换为 PCD </button> <button :disabled="!pcdData" @click="downloadPCD" class="download-btn" > <svg-icon-download /> 下载 PCD 文件 </button> </div> <div v-if="error" class="error-message"> {{ error }} </div> <div v-if="conversionInfo" class="conversion-info"> <h3>转换信息</h3> <p>点数: {{ conversionInfo.pointCount.toLocaleString() }}</p> <p>文件大小: {{ formatFileSize(conversionInfo.fileSize) }}</p> <p>处理时间: {{ conversionInfo.processingTime }}ms</p> </div> </div> </template> <script setup> import { ref } from &#39;vue&#39;; import { parseLASFile } from &#39;@/utils/lasParser&#39;; import { generatePCD } from &#39;@/utils/pcdGenerator&#39;; import { saveAs } from &#39;file-saver&#39;; const emit = defineEmits([&#39;file-converted&#39;]); const currentFile = ref(null); const isProcessing = ref(false); const progress = ref(0); const pcdData = ref(null); const error = ref(null); const conversionInfo = ref(null); const triggerFileInput = () => { document.getElementById(&#39;las-file&#39;).click(); }; const handleFileUpload = (e) => { const file = e.target.files[0]; if (file) { handleFile(file); } }; const handleDrop = (e) => { const file = e.dataTransfer.files[0]; if (file && (file.name.endsWith(&#39;.las&#39;) || file.name.endsWith(&#39;.laz&#39;))) { handleFile(file); } else { error.value = &#39;请上传 LAS 或 LAZ 文件&#39;; } }; const handleFile = (file) => { currentFile.value = file; pcdData.value = null; conversionInfo.value = null; error.value = null; }; const convertToPCD = async () => { if (!currentFile.value) return; isProcessing.value = true; progress.value = 0; error.value = null; try { const startTime = performance.now(); // 解析 LAS 文件 const lasData = await parseLASFile(currentFile.value, (p) => { progress.value = Math.floor(p * 100); }); // 转换为 PCD 格式 pcdData.value = generatePCD(lasData); const endTime = performance.now(); conversionInfo.value = { pointCount: lasData.points.length, fileSize: currentFile.value.size, processingTime: Math.round(endTime - startTime) }; // 发送点云数据给可视化组件 emit(&#39;file-converted&#39;, { points: lasData.points, colors: lasData.colors, pcdData: pcdData.value }); } catch (err) { console.error(&#39;转换失败:&#39;, err); error.value = `转换失败: ${err.message || &#39;未知错误&#39;}`; } finally { isProcessing.value = false; } }; const downloadPCD = () => { if (!pcdData.value) return; const blob = new Blob([pcdData.value], { type: &#39;text/plain&#39; }); const filename = currentFile.value.name.replace(/\.[^/.]+$/, &#39;&#39;) + &#39;.pcd&#39;; saveAs(blob, filename); }; const formatFileSize = (bytes) => { if (bytes === 0) return &#39;0 Bytes&#39;; const k = 1024; const sizes = [&#39;Bytes&#39;, &#39;KB&#39;, &#39;MB&#39;, &#39;GB&#39;]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + &#39; &#39; + sizes[i]; }; </script> <style scoped> .converter-container { background: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); height: 100%; display: flex; flex-direction: column; } .upload-area { border: 2px dashed #6a11cb; border-radius: 10px; padding: 40px 20px; text-align: center; background: #f9f7ff; cursor: pointer; transition: all 0.3s ease; flex-grow: 1; display: flex; align-items: center; justify-content: center; min-height: 250px; } .upload-area:hover { border-color: #2575fc; background: #f0edff; } .upload-content { color: #555; } .upload-icon { width: 60px; height: 60px; margin-bottom: 15px; fill: #6a11cb; } button { background: #6a11cb; color: white; border: none; padding: 12px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; transition: all 0.2s; margin: 5px; } button:disabled { background: #cccccc; cursor: not-allowed; } button:hover:not(:disabled) { background: #2575fc; transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); } .convert-btn { background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); } .download-btn { background: linear-gradient(135deg, #00b09b 0%, #96c93d 100%); } .controls { display: flex; justify-content: center; margin: 25px 0; gap: 15px; flex-wrap: wrap; } .processing { display: flex; flex-direction: column; align-items: center; gap: 15px; } .spinner { width: 50px; height: 50px; border: 5px solid rgba(106, 17, 203, 0.2); border-top: 5px solid #6a11cb; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .error-message { background: #ffebee; color: #b71c1c; padding: 15px; border-radius: 8px; margin-top: 20px; text-align: center; } .conversion-info { background: #e3f2fd; padding: 15px; border-radius: 8px; margin-top: 20px; } .conversion-info h3 { margin-top: 0; color: #0d47a1; border-bottom: 1px solid #bbdefb; padding-bottom: 8px; } .file-info { margin-top: 10px; color: #6a11cb; font-weight: 500; } </style> ``` ### 3. 点云可视化组件 (src/components/PointCloudViewer.vue) ```vue <template> <div class="viewer-container"> <div ref="viewer" class="point-cloud-viewer"></div> <div class="viewer-controls"> <div class="control-group"> <label>点大小</label> <input type="range" min="1" max="20" v-model="pointSize" @input="updatePointSize"> </div> <div class="control-group"> <label>亮度</label> <input type="range" min="0.1" max="2" step="0.1" v-model="brightness" @input="updateBrightness"> </div> <div class="control-group"> <label>显示模式</label> <select v-model="renderMode" @change="updateRenderMode"> <option value="points">点模式</option> <option value="spheres">球体模式</option> </select> </div> <button @click="resetCamera"> <svg-icon-reset /> 重置视图 </button> </div> <div v-if="loading" class="loading-overlay"> <div class="loading-spinner"></div> <p>加载点云数据中...</p> </div> <div class="stats"> <p>点数: {{ stats.pointCount.toLocaleString() }}</p> <p>FPS: {{ stats.fps }}</p> </div> </div> </template> <script setup> import { ref, onMounted, onBeforeUnmount, watch } from &#39;vue&#39;; import * as THREE from &#39;three&#39;; import { OrbitControls } from &#39;three/addons/controls/OrbitControls.js&#39;; import { PCDLoader } from &#39;three/addons/loaders/PCDLoader.js&#39;; const props = defineProps({ pointData: Object }); const viewer = ref(null); let scene, camera, renderer, controls, pointCloud; const pointSize = ref(2); const brightness = ref(1); const renderMode = ref(&#39;points&#39;); const loading = ref(false); const stats = ref({ pointCount: 0, fps: 0 }); // 初始化 Three.js 场景 const initScene = () => { // 创建场景 scene = new THREE.Scene(); scene.background = new THREE.Color(0x222222); // 创建相机 camera = new THREE.PerspectiveCamera( 75, viewer.value.clientWidth / viewer.value.clientHeight, 0.1, 10000 ); camera.position.z = 50; // 创建渲染器 renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(viewer.value.clientWidth, viewer.value.clientHeight); renderer.setPixelRatio(window.devicePixelRatio); viewer.value.appendChild(renderer.domElement); // 添加轨道控制器 controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; // 添加光源 const ambientLight = new THREE.AmbientLight(0x404040, 1); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(1, 1, 1); scene.add(directionalLight); // 添加坐标轴辅助 const axesHelper = new THREE.AxesHelper(20); scene.add(axesHelper); // 开始动画循环 animate(); }; // 加载点云数据 const loadPointCloud = async (pointData) => { if (!pointData) return; loading.value = true; try { // 清除现有点云 if (pointCloud) { scene.remove(pointCloud); pointCloud.geometry.dispose(); pointCloud.material.dispose(); } // 创建点云几何体 const geometry = new THREE.BufferGeometry(); // 设置顶点位置 const positions = new Float32Array(pointData.points.length * 3); for (let i = 0; i < pointData.points.length; i++) { positions[i * 3] = pointData.points[i].x; positions[i * 3 + 1] = pointData.points[i].y; positions[i * 3 + 2] = pointData.points[i].z; } geometry.setAttribute(&#39;position&#39;, new THREE.BufferAttribute(positions, 3)); // 设置颜色(如果有) let colors = null; if (pointData.colors && pointData.colors.length === pointData.points.length) { colors = new Float32Array(pointData.points.length * 3); for (let i = 0; i < pointData.colors.length; i++) { colors[i * 3] = pointData.colors[i].r / 255; colors[i * 3 + 1] = pointData.colors[i].g / 255; colors[i * 3 + 2] = pointData.colors[i].b / 255; } geometry.setAttribute(&#39;color&#39;, new THREE.BufferAttribute(colors, 3)); } // 创建点云材质 const material = new THREE.PointsMaterial({ size: pointSize.value, vertexColors: !!colors, sizeAttenuation: false }); // 创建点云对象 pointCloud = new THREE.Points(geometry, material); scene.add(pointCloud);
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值