Forráskód Böngészése

模型导航代码提交

luogang 1 éve
commit
55454f2a3e
65 módosított fájl, 4502 hozzáadás és 0 törlés
  1. 38 0
      .eslintrc.cjs
  2. 24 0
      .gitignore
  3. 23 0
      README.md
  4. 25 0
      components.d.ts
  5. 13 0
      index.html
  6. 5 0
      mock/filePath.ts
  7. 4 0
      mock/index.ts
  8. 0 0
      mock/json/movie.json
  9. 1 0
      mock/json/movieArea.json
  10. 1 0
      mock/json/movieType.json
  11. 68 0
      mock/movie.ts
  12. 58 0
      mock/util.ts
  13. 39 0
      package.json
  14. 11 0
      postcss.config.cjs
  15. BIN
      public/models/scene.glb
  16. BIN
      public/path/air.glb
  17. BIN
      public/path/airport.glb
  18. BIN
      public/path/coordinate.png
  19. BIN
      public/path/cycle_light.png
  20. BIN
      public/path/floor_10_nav.glb
  21. BIN
      public/path/floor_9_nav.glb
  22. BIN
      public/path/floor_9_new.glb
  23. BIN
      public/path/out_nav.glb
  24. BIN
      public/path/person.fbx
  25. BIN
      public/path/pipe.png
  26. 1 0
      public/vite.svg
  27. 10 0
      src/App.vue
  28. 11 0
      src/api/index.ts
  29. 1 0
      src/assets/vue.svg
  30. 38 0
      src/axios-config.ts
  31. 39 0
      src/components/FilterRate.vue
  32. 119 0
      src/components/Form.vue
  33. 74 0
      src/components/List.vue
  34. 55 0
      src/components/Table.vue
  35. 688 0
      src/hooks/MeshLine.js
  36. 20 0
      src/hooks/Utils.ts
  37. 7 0
      src/hooks/useGLTFLoader.ts
  38. 243 0
      src/hooks/useMesh.ts
  39. 8 0
      src/main.ts
  40. 20 0
      src/router/index.ts
  41. 24 0
      src/style.css
  42. 39 0
      src/utils/EventEmitter.ts
  43. 90 0
      src/views/home/HomeIndex.vue
  44. 354 0
      src/views/home/index.vue
  45. 513 0
      src/views/home/map.vue
  46. 151 0
      src/views/home/path.vue
  47. 263 0
      src/views/threeMap/components/Character/Character.ts
  48. 3 0
      src/views/threeMap/components/Character/Constants.ts
  49. 226 0
      src/views/threeMap/components/PathFinder/Graph.ts
  50. 45 0
      src/views/threeMap/components/PathFinder/PathFinder.ts
  51. 199 0
      src/views/threeMap/components/PathFinder/WeightedGraph.ts
  52. 72 0
      src/views/threeMap/components/PathFinder/initConnectPoint.ts
  53. 93 0
      src/views/threeMap/components/PathFinder/useFloorConnectFinder.ts
  54. 10 0
      src/views/threeMap/components/ThreeMap/Constants.ts
  55. 248 0
      src/views/threeMap/components/ThreeMap/NavMap.vue
  56. 234 0
      src/views/threeMap/components/ThreeMap/ThreeMap.vue
  57. 29 0
      src/views/threeMap/components/ThreeMap/useCSS2D.ts
  58. 95 0
      src/views/threeMap/components/ThreeMap/useInitThree.ts
  59. 40 0
      src/views/threeMap/components/ThreeMap/useRayCast.ts
  60. 8 0
      src/views/threeMap/global.ts
  61. 24 0
      src/views/threeMap/index.vue
  62. 7 0
      src/vite-env.d.ts
  63. 38 0
      tsconfig.json
  64. 9 0
      tsconfig.node.json
  65. 44 0
      vite.config.ts

+ 38 - 0
.eslintrc.cjs

@@ -0,0 +1,38 @@
+module.exports = {
+    "env": {
+        "browser": true,
+        "es2021": true,
+        "node": true
+    },
+    "extends": [
+        // "eslint:recommended",
+        "plugin:vue/vue3-essential",
+        // "plugin:@typescript-eslint/recommended"
+    ],
+    "overrides": [
+    ],
+    "parser": "vue-eslint-parser",
+    "parserOptions": {
+        "ecmaVersion": "latest",
+        "sourceType": "module",
+        "parser": "@typescript-eslint/parser"
+    },
+    "plugins": [
+        "vue",
+        "@typescript-eslint"
+    ],
+    "rules": {
+        "@typescript-eslint/ban-types": [
+            "error",
+            {
+              "extendDefaults": true,
+              "types": {
+                "{}": false
+              }
+            }
+        ],
+        "@typescript-eslint/no-explicit-any": "off",
+        "vue/multi-word-component-names": "off",
+        "no-debugger":'off'
+    }
+}

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+package-lock.json
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 23 - 0
README.md

@@ -0,0 +1,23 @@
+# Vue 3 + TypeScript + Vite
+一个 vite + vue3 + typescript + vant+ router + axios + mock + eslint 移动端基础模板,并包含一些通用组件和方法
+
+## 安装依赖
+```
+npm install
+```
+
+## 开发运行
+```
+npm run dev
+```
+
+## 打包部署
+```
+npm run build
+```
+
+## 项目预览
+需要先打包,再预览
+```
+npm run preview
+```

+ 25 - 0
components.d.ts

@@ -0,0 +1,25 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+import '@vue/runtime-core'
+
+export {}
+
+declare module '@vue/runtime-core' {
+  export interface GlobalComponents {
+    FilterRate: typeof import('./src/components/FilterRate.vue')['default']
+    Form: typeof import('./src/components/Form.vue')['default']
+    List: typeof import('./src/components/List.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    Table: typeof import('./src/components/Table.vue')['default']
+    VanCard: typeof import('vant/es')['Card']
+    VanEmpty: typeof import('vant/es')['Empty']
+    VanList: typeof import('vant/es')['List']
+    VanPullRefresh: typeof import('vant/es')['PullRefresh']
+    VanTabbar: typeof import('vant/es')['Tabbar']
+    VanTabbarItem: typeof import('vant/es')['TabbarItem']
+  }
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>map</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 5 - 0
mock/filePath.ts

@@ -0,0 +1,5 @@
+// 文件路径
+
+export const movieAreaFile = '/json/movieArea.json'
+export const movieTypeFile = '/json/movieType.json'
+export const movieFile = '/json/movie.json'

+ 4 - 0
mock/index.ts

@@ -0,0 +1,4 @@
+// 一些单独接口
+export default [
+  
+]

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
mock/json/movie.json


+ 1 - 0
mock/json/movieArea.json

@@ -0,0 +1 @@
+[{"name":"大陆","createTime":"2023-03-23 10:36:59","id":1},{"name":"香港","createTime":"2023-03-23 03:48:52","id":2,"count":1},{"name":"台湾","createTime":"2023-03-23 03:48:57","id":3},{"name":"韩国","createTime":"2023-03-23 03:49:02","id":4},{"name":"印度","createTime":"2023-03-23 03:51:02","id":5},{"name":"泰国","createTime":"2023-03-23 03:51:17","id":6},{"name":"美国","createTime":"2023-03-23 03:51:20","id":7},{"name":"英国","createTime":"2023-03-23 03:51:37","id":8,"count":1},{"name":"德国","createTime":"2023-03-23 03:51:41","id":9},{"name":"日本","createTime":"2023-03-23 03:52:06","id":10},{"name":"澳大利亚","createTime":"2023-03-23 03:52:21","id":11},{"name":"新加坡","createTime":"2023-03-23 03:52:27","id":12},{"name":"新西兰","createTime":"2023-03-23 03:52:33","id":13},{"name":"荷兰","createTime":"2023-03-23 03:52:37","id":14}]

+ 1 - 0
mock/json/movieType.json

@@ -0,0 +1 @@
+[{"name":"动作","createTime":"2023-03-24 10:45:31","id":1,"count":1},{"name":"爱情","createTime":"2023-03-24 10:45:36","id":2},{"name":"喜剧","createTime":"2023-03-24 10:45:41","id":3},{"name":"科幻","createTime":"2023-03-24 10:45:49","id":4},{"name":"悬疑","createTime":"2023-03-24 10:45:57","id":5},{"name":"惊悚","createTime":"2023-03-24 10:46:03","id":6},{"name":"灾难","createTime":"2023-03-24 10:46:16","id":7},{"name":"记录","createTime":"2023-03-24 10:46:30","id":8},{"name":"战争","createTime":"2023-03-24 10:46:44","id":9},{"name":"历史","createTime":"2023-03-24 10:47:26","id":10},{"name":"古装","createTime":"2023-03-24 10:47:52","id":11},{"name":"系列","createTime":"2023-03-24 10:48:30","id":12},{"name":"动画","createTime":"2023-03-24 10:49:10","id":13,"count":1}]

+ 68 - 0
mock/movie.ts

@@ -0,0 +1,68 @@
+import { MockMethod } from 'vite-plugin-mock'
+// 电影管理相关接口
+import { getFileContext, doPagination, responseStatus, getDataByKey } from './util'
+import { movieAreaFile, movieTypeFile, movieFile } from './filePath'
+export default [
+  // 获取电影列表
+  {
+    url: '/api/movie/movieList',
+    method: 'get',
+    timeout: 500,
+    response: ({ query }) => {
+      const data = getFileContext(movieFile)
+      const res = doPagination(data.reverse(), query.page, query.pageSize)
+      return {
+        ...responseStatus(),
+        data: res,
+        total: data.length
+      }
+    }
+  },
+  {
+    url: '/api/movie/movieInfo',
+    method: 'get',
+    timeout: 200,
+    response: ({ query }) => {
+      const data = getFileContext(movieFile)
+      const res = getDataByKey(data, Number(query.id))
+      if (res) {
+        return {
+          ...responseStatus(),
+          data: res
+        }
+      } else {
+        return {
+          ...responseStatus(2)
+        }
+      }
+    }
+  },
+  // 获取地区
+  {
+    url: '/api/movie/movieAreaList',
+    method: 'get',
+    timeout: 200,
+    response: () => {
+      const res = getFileContext(movieAreaFile)
+      return {
+        ...responseStatus(),
+        data: res.reverse(),
+        total: res.length
+      }
+    }
+  },
+  // 获取类型
+  {
+    url: '/api/movie/movieTypeList',
+    method: 'get',
+    timeout: 200,
+    response: () => {
+      const res = getFileContext(movieTypeFile)
+      return {
+        ...responseStatus(),
+        data: res.reverse(),
+        total: res.length
+      }
+    }
+  }
+] as MockMethod[]

+ 58 - 0
mock/util.ts

@@ -0,0 +1,58 @@
+// 一些通用方法/配置
+
+import fs from 'fs'
+// 获取json数据
+export function getFileContext (filePath: string): [] {
+  return JSON.parse(fs.readFileSync(__dirname + filePath, { encoding: 'utf-8' }) || '[]')
+}
+
+// 保存json数据
+export function setFileContext (filePath: string, context: [] = []) {
+  return fs.writeFileSync(__dirname + filePath, JSON.stringify(context), { encoding: 'utf-8' })
+}
+
+// 根据id或者其他条件返回对应数据
+export function getDataByKey (data, value, key = 'id') {
+  const res = data.find((item) => value === item[key])
+  return res
+}
+// 根据id新增一条数据
+export function addOneData (data, pushData) {
+  pushData.id = data.length ? Number(data[data.length - 1].id) + 1 : 1
+  data.push(pushData)
+}
+// 更新一条数据
+export function updateOneData (data, updateData) {
+  const index = data.findIndex(item => Number(item.id) === Number(updateData.id))
+  data[index] = updateData
+}
+// 删除一条数据
+export function removeOneData (data, id) {
+  const index = data.findIndex(item => Number(item.id) === Number(id))
+  data.splice(index, 1)
+}
+
+// 分页
+export function doPagination (data = [], page = 1, size = 10) {
+  const start = --page * Number(size)
+  return data.slice(start, start + Number(size))
+}
+
+// 通用状态码
+export function responseStatus (i = 1) {
+  const s = {
+    1: {
+      code: 1,
+      msg: '请求成功'
+    },
+    2: {
+      code: 2,
+      msg: '请求失败'
+    },
+    3: {
+      code: 3,
+      msg: '未登录'
+    }
+  }
+  return s[i]
+}

+ 39 - 0
package.json

@@ -0,0 +1,39 @@
+{
+  "name": "vue-varlet-ts-ssmp",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview",
+    "lint": "eslint . --ext .vue,js,ts,jsx,tsx --fix"
+  },
+  "dependencies": {
+    "@antv/l7": "^2.22.4",
+    "@antv/l7-three": "^2.22.4",
+    "axios": "^1.3.4",
+    "pathfinding": "^0.4.18",
+    "three": "^0.174.0",
+    "three-pathfinding": "^1.3.0",
+    "vant": "^4.1.2",
+    "vue": "^3.2.47",
+    "vue-router": "^4.1.6",
+    "three.path": "^1.0.1"
+  },
+  "devDependencies": {
+    "@typescript-eslint/eslint-plugin": "^5.57.1",
+    "@typescript-eslint/parser": "^5.57.1",
+    "@vitejs/plugin-vue": "^4.1.0",
+    "eslint": "^8.38.0",
+    "eslint-plugin-vue": "^9.10.0",
+    "postcss-px-to-viewport": "^1.1.1",
+    "sass": "^1.85.1",
+    "typescript": "^4.9.3",
+    "unplugin-vue-components": "^0.24.1",
+    "vite": "^4.2.0",
+    "vite-plugin-eslint": "^1.8.1",
+    "vite-plugin-mock": "^2.9.6",
+    "vue-tsc": "^1.2.0"
+  }
+}

+ 11 - 0
postcss.config.cjs

@@ -0,0 +1,11 @@
+// css适配,px -> vw
+module.exports = {
+  plugins: {
+    'postcss-px-to-viewport': {
+      viewportWidth: 375,
+      unitPrecision: 6,
+      unitToConvert: 'px',
+      propList: ['*']
+    }
+  }
+}

BIN
public/models/scene.glb


BIN
public/path/air.glb


BIN
public/path/airport.glb


BIN
public/path/coordinate.png


BIN
public/path/cycle_light.png


BIN
public/path/floor_10_nav.glb


BIN
public/path/floor_9_nav.glb


BIN
public/path/floor_9_new.glb


BIN
public/path/out_nav.glb


BIN
public/path/person.fbx


BIN
public/path/pipe.png


+ 1 - 0
public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 10 - 0
src/App.vue

@@ -0,0 +1,10 @@
+<script setup lang="ts">
+</script>
+
+<template>
+  <RouterView></RouterView>
+</template>
+
+<style scoped>
+
+</style>

+ 11 - 0
src/api/index.ts

@@ -0,0 +1,11 @@
+import axios from '../axios-config'
+import type { InternalAxiosRequestConfig } from 'axios'
+import type { otherConfig } from '../axios-config'
+export function getMovieList(params?: {}) {
+  return axios({
+    url: '/movie/movieList',
+    method: 'get',
+    params,
+    hideLoading: true
+  } as InternalAxiosRequestConfig & otherConfig)
+}

+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 38 - 0
src/axios-config.ts

@@ -0,0 +1,38 @@
+import axios from 'axios'
+import type { InternalAxiosRequestConfig } from 'axios'
+import { showLoadingToast } from 'vant';
+let msgCtrl: any = null
+const request = axios.create({
+  baseURL: '/api',
+  timeout: 10000
+})
+export type otherConfig = {
+  hideLoading?: boolean
+}
+// 请求拦截
+request.interceptors.request.use((config: InternalAxiosRequestConfig<{}> & otherConfig) => {
+  if(!config.hideLoading) msgCtrl = showLoadingToast('加载中')
+  // 发送请求前处理
+  config.headers.tokenId = localStorage.getItem('tokenId')
+  return config
+}, (err) => {
+  // 请求失败处理
+  return Promise.reject(err)
+})
+
+// 响应拦截
+request.interceptors.response.use((res) => {
+  console.log(res.data)
+  msgCtrl && msgCtrl.close()
+  msgCtrl = null // 还原初始值
+  const { data } = res
+  if(data.code === 1) {
+    return res.data
+  } else {
+    return Promise.reject(data)
+  }
+}, (err) => {
+  return Promise.reject(err)
+})
+
+export default request

+ 39 - 0
src/components/FilterRate.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import { reactive, toRefs } from 'vue';
+
+const props = defineProps(['data'])
+const emit = defineEmits(['change'])
+const { data = [] } = toRefs(props)
+const selections = reactive<string[]>([])
+function change(index: number, value: string) {
+  selections[index] = value
+  emit('change', selections)
+}
+</script>
+
+<template>
+  <div class="filter">
+    <van-row class="filter-row" v-for="(item, index) in data" :key="item.title" :wrap="false" >
+      <van-col :span="3">{{ item.title }}</van-col>
+      <van-row>
+        <van-col class="filter-gutter" :class="{ active: selections[index] === opt }" v-for="opt in item.options" :key="opt" @click="change(index, opt)">{{ opt }}</van-col>
+      </van-row>
+    </van-row>
+  </div>
+</template>
+
+<style scoped>
+.filter {
+  padding: 10px;
+  background: #fff;
+}
+.filter-row {
+  margin-bottom: 5px;
+}
+.filter-gutter {
+  margin-right: 10px;
+}
+.filter-gutter.active {
+  color: var(--van-primary-color);
+}
+</style>

+ 119 - 0
src/components/Form.vue

@@ -0,0 +1,119 @@
+<script setup lang="ts">
+import { toRefs } from 'vue'
+const props = defineProps(['formData', 'form'])
+const { formData, form } = toRefs(props)
+</script>
+
+<template>
+  <van-form style="padding-top: 15px;">
+    <van-cell-group inset>
+      <template v-for="(item, key) in formData" :key="key">
+        <!-- 选择器-->
+        <template v-if="item.type === 'select'" >
+          <van-field v-model="form[key]" is-link readonly :placeholder="item.placeholder" :label="item.label" :rules="item.rules" :required="item.required" @click="item.showPicker = true" />
+          <van-popup v-model:show="item.showPicker" position="bottom">
+            <van-picker
+              :title="item.label"
+              :columns="item.options"
+              @confirm="(e) => { item.onConfirm(e); item.showPicker = false }"
+              @cancel="item.showPicker = false"
+            />
+          </van-popup>
+        </template>
+        <!-- 级联选择器 -->
+        <template v-else-if="item.type === 'cascader'">
+          <van-field
+            v-model="form[key]"
+            is-link
+            readonly
+            :label="item.label"
+            :placeholder="item.placeholder || '请选择'"
+            :required="item.required"
+            @click="item.showPicker = true"
+          />
+          <van-popup v-model:show="item.showPicker" round position="bottom">
+            <van-cascader
+              v-model="form[key]"
+              :title="item.placeholder || '请选择'"
+              :options="item.options"
+              @close="item.showPicker = false"
+              @finish="item.onFinish"
+            />
+          </van-popup>
+        </template>
+        <!-- 日期选择 -->
+        <template v-else-if="item.type === 'datePicker'">
+          <van-field
+            v-model="form[key]"
+            is-link
+            readonly
+            name="datePicker"
+            :label="item.label"
+            :placeholder="item.placeholder || '点击选择时间'"
+            :required="item.required"
+            @click="item.showPicker = true"
+          />
+          <van-popup v-model:show="item.showPicker" position="bottom">
+            <van-date-picker @confirm="($event) => { item.onConfirm($event); item.showPicker = false}" @cancel="item.showPicker = false" />
+          </van-popup>
+        </template>
+        <!-- 开关 -->
+        <template v-else-if="item.type === 'switch'">
+          <van-field :label="item.label" :required="item.required">
+            <template #input>
+              <van-switch v-model="form[key]" />
+            </template>
+          </van-field>
+        </template>
+        <!-- 复选框 -->
+        <template v-else-if="item.type === 'checkbox'">
+          <van-field :label="item.label" :required="item.required">
+            <template #input>
+              <van-checkbox-group v-model="form[key]" direction="horizontal">
+                <van-checkbox :name="opt.name" shape="square" v-for="opt in item.options" :key="opt.label">{{ opt.label }}</van-checkbox>
+              </van-checkbox-group>
+            </template>
+          </van-field>
+        </template>
+        <!-- 单选 -->
+        <template v-else-if="item.type === 'radio'">
+          <van-field :label="item.label" :required="item.required">
+            <template #input>
+              <van-radio-group v-model="form[key]" direction="horizontal">
+                <van-radio :name="opt.name" v-for="opt in item.options" :key="opt.label">{{ opt.label }}</van-radio>
+              </van-radio-group>
+            </template>
+          </van-field>
+        </template>
+        
+        <!-- 文件上传 -->
+        <template v-else-if="item.type === 'upload'">
+          <van-field :label="item.label" :required="item.required">
+            <template #input>
+              <van-uploader v-model="form[key]" v-bind="item" />
+            </template>
+          </van-field>
+        </template>
+        <!-- 评分 -->
+        <template v-else-if="item.type === 'rate'">
+          <van-field :label="item.label" :required="item.required">
+            <template #input>
+              <van-rate v-model="form[key]" v-bind="item" />
+            </template>
+          </van-field>
+        </template>
+        <!-- 单行输入,type可以是text、number、password等-->
+        <van-field v-model="form[key]" v-bind="item" clearable v-else />
+      </template>
+    </van-cell-group>
+    <div style="margin: var(--van-cell-group-inset-padding);">
+      <slot>
+        <van-button type="primary" block native-type="submit" style="margin-top: 15px;">保存</van-button>
+      </slot>
+    </div>
+  </van-form>
+</template>
+
+<style scoped>
+
+</style>

+ 74 - 0
src/components/List.vue

@@ -0,0 +1,74 @@
+<script lang="ts" setup>
+import { ref } from 'vue';
+const props = defineProps({
+  // 数据请求方法
+  getData: {
+    type: Function,
+    required: true
+  },
+  // 返回结果
+  modelValue: {
+    type: Array,
+    required: true
+  },
+  // 请求参数
+  parmas: {
+    type: Object,
+    default () {
+      return {}
+    }
+  }
+})
+const emit = defineEmits(['update:modelValue'])
+const loading = ref(false);
+const finished = ref(false);
+const refreshing = ref(false)
+let page = 1 // 页码
+let pageSize = 10 // 每页数量
+let len = 0 // 和总数比较确定是否已全部加载
+// 刷新
+const onRefresh = () => {
+  page = 1
+  len = 0
+  finished.value = false
+  refreshing.value = true
+  emit('update:modelValue', [])
+  onLoad()
+};
+
+// 获取数据
+const onLoad = async () => {
+  // 异步更新数据
+  props.getData({ page, pageSize, ...props.parmas }).then((res: any) => {
+    const {data, total} = res
+    emit('update:modelValue', props.modelValue.concat(data))
+    len += data.length
+    if(len >= total) {
+      finished.value = true
+    }
+    page++
+  }).catch((err: any) => {
+    console.log(err)
+    finished.value = true
+  }).finally(() => {
+    // 加载状态结束
+    refreshing.value = false
+    loading.value = false
+  })
+};
+</script>
+
+<template>
+  <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
+    <van-list
+      v-model:loading="loading"
+      :finished="finished"
+      :finished-text="props.modelValue.length ? '没有更多了' : ''"
+      @load="onLoad"
+    >
+      <slot />
+      <van-empty v-if="props.modelValue.length === 0 && !refreshing && !loading" description="暂无数据" />
+    </van-list>
+
+  </van-pull-refresh>
+</template>

+ 55 - 0
src/components/Table.vue

@@ -0,0 +1,55 @@
+<script lang="ts" setup>
+import { toRefs, PropType } from 'vue';
+interface tableData {
+  [key: string]: any
+}
+const props = defineProps({
+  data: {
+    type: Array as PropType<tableData[]>,
+    required: true,
+    default: () => []
+  },
+  cols: {
+    type: Array as PropType<tableData[]>,
+    required: true,
+    default: () => []
+  },
+  rowKey: {
+    type: String,
+    required: true
+  },
+  btmLine: {
+    type: Boolean,
+    default: true
+  }
+})
+const { data, cols, rowKey, btmLine } = toRefs(props)
+const span = Math.floor(24 / cols?.value.length)
+</script>
+<template>
+  <div class="xg-table">
+    <van-row class="table-row" :wrap="false" justify="space-around" >
+      <van-col class="van-ellipsis header-title" :span="item.span || span" v-for="item in cols" :key="item.title">{{ item.title }}</van-col>
+    </van-row>
+    <van-row class="table-row" :class="{ 'btm-line': btmLine }" v-for="row in data" :key="row[rowKey]" :wrap="false" justify="space-around">
+      <van-col v-for="item in cols" :key="item.title" :span="item.span || span">{{ row[item.key] }}</van-col>
+    </van-row>
+  </div>
+</template>
+
+<style scoped>
+.xg-table {
+  padding: 10px;
+  font-size: 14px;
+  background: var(--van-background-2);
+}
+.header-title {
+  font-weight: 600;
+}
+.table-row {
+  padding: 8px 0;
+}
+.btm-line {
+  border-bottom: 1px solid #ddd;
+}
+</style>

+ 688 - 0
src/hooks/MeshLine.js

@@ -0,0 +1,688 @@
+import * as THREE from "three";
+
+export class MeshLine extends THREE.BufferGeometry {
+	constructor() {
+		super();
+		this.isMeshLine = true;
+		this.type = 'MeshLine';
+
+		this.positions = [];
+
+		this.previous = [];
+		this.next = [];
+		this.side = [];
+		this.width = [];
+		this.indices_array = [];
+		this.uvs = [];
+		this.counters = [];
+		this._points = [];
+		this._geom = null;
+
+		this.widthCallback = null;
+
+		// Used to raycast
+		this.matrixWorld = new THREE.Matrix4();
+
+		Object.defineProperties(this, {
+			// this is now a bufferGeometry
+			// add getter to support previous api
+			geometry: {
+				enumerable: true,
+				get: function() {
+					return this;
+				},
+			},
+			geom: {
+				enumerable: true,
+				get: function() {
+					return this._geom;
+				},
+				set: function(value) {
+					this.setGeometry(value, this.widthCallback);
+				},
+			},
+			// for declaritive architectures
+			// to return the same value that sets the points
+			// eg. this.points = points
+			// console.log(this.points) -> points
+			points: {
+				enumerable: true,
+				get: function() {
+					return this._points;
+				},
+				set: function(value) {
+					this.setPoints(value, this.widthCallback);
+				},
+			},
+		});
+	}
+}
+
+MeshLine.prototype.setMatrixWorld = function(matrixWorld) {
+	this.matrixWorld = matrixWorld;
+};
+
+// setting via a geometry is rather superfluous
+// as you're creating a unecessary geometry just to throw away
+// but exists to support previous api
+MeshLine.prototype.setGeometry = function(g, c) {
+	// as the input geometry are mutated we store them
+	// for later retreival when necessary (declaritive architectures)
+	this._geometry = g;
+	this.setPoints(g.getAttribute("position").array, c);
+};
+
+MeshLine.prototype.setPoints = function(points, wcb) {
+	if (!(points instanceof Float32Array) && !(points instanceof Array)) {
+		console.error(
+			"ERROR: The BufferArray of points is not instancied correctly."
+		);
+		return;
+	}
+	// as the points are mutated we store them
+	// for later retreival when necessary (declaritive architectures)
+	this._points = points;
+	this.widthCallback = wcb;
+	this.positions = [];
+	this.counters = [];
+	if (points.length && points[0] instanceof THREE.Vector3) {
+		// could transform Vector3 array into the array used below
+		// but this approach will only loop through the array once
+		// and is more performant
+		for (var j = 0; j < points.length; j++) {
+			var p = points[j];
+			var c = j / points.length;
+			this.positions.push(p.x, p.y, p.z);
+			this.positions.push(p.x, p.y, p.z);
+			this.counters.push(c);
+			this.counters.push(c);
+		}
+	} else {
+		for (var j = 0; j < points.length; j += 3) {
+			var c = j / points.length;
+			this.positions.push(points[j], points[j + 1], points[j + 2]);
+			this.positions.push(points[j], points[j + 1], points[j + 2]);
+			this.counters.push(c);
+			this.counters.push(c);
+		}
+	}
+	this.process();
+};
+
+function MeshLineRaycast(raycaster, intersects) {
+	var inverseMatrix = new THREE.Matrix4();
+	var ray = new THREE.Ray();
+	var sphere = new THREE.Sphere();
+	var interRay = new THREE.Vector3();
+	var geometry = this.geometry;
+	// Checking boundingSphere distance to ray
+
+	if (!geometry.boundingSphere) geometry.computeBoundingSphere();
+	sphere.copy(geometry.boundingSphere);
+	sphere.applyMatrix4(this.matrixWorld);
+
+	if (raycaster.ray.intersectSphere(sphere, interRay) === false) {
+		return;
+	}
+
+	inverseMatrix.copy(this.matrixWorld).invert();
+	ray.copy(raycaster.ray).applyMatrix4(inverseMatrix);
+
+	var vStart = new THREE.Vector3();
+	var vEnd = new THREE.Vector3();
+	var interSegment = new THREE.Vector3();
+	var step = this instanceof THREE.LineSegments ? 2 : 1;
+	var index = geometry.index;
+	var attributes = geometry.attributes;
+
+	if (index !== null) {
+		var indices = index.array;
+		var positions = attributes.position.array;
+		var widths = attributes.width.array;
+
+		for (var i = 0, l = indices.length - 1; i < l; i += step) {
+			var a = indices[i];
+			var b = indices[i + 1];
+
+			vStart.fromArray(positions, a * 3);
+			vEnd.fromArray(positions, b * 3);
+			var width = widths[Math.floor(i / 3)] !== undefined ? widths[Math.floor(i / 3)] : 1;
+			var precision = raycaster.params.Line.threshold + (this.material.lineWidth * width) / 2;
+			var precisionSq = precision * precision;
+
+			var distSq = ray.distanceSqToSegment(vStart, vEnd, interRay, interSegment);
+
+			if (distSq > precisionSq) continue;
+
+			interRay.applyMatrix4(this.matrixWorld); // Move back to world space for distance calculation
+
+			var distance = raycaster.ray.origin.distanceTo(interRay);
+
+			if (distance < raycaster.near || distance > raycaster.far) continue;
+
+			intersects.push({
+				distance: distance,
+				// What do we want? intersection point on the ray or on the segment??
+				// point: raycaster.ray.at( distance ),
+				point: interSegment.clone().applyMatrix4(this.matrixWorld),
+				index: i,
+				face: null,
+				faceIndex: null,
+				object: this,
+			});
+			// make event only fire once
+			i = l;
+		}
+	}
+}
+MeshLine.prototype.raycast = MeshLineRaycast;
+MeshLine.prototype.compareV3 = function(a, b) {
+	var aa = a * 6;
+	var ab = b * 6;
+	return (
+		this.positions[aa] === this.positions[ab] &&
+		this.positions[aa + 1] === this.positions[ab + 1] &&
+		this.positions[aa + 2] === this.positions[ab + 2]
+	);
+};
+
+MeshLine.prototype.copyV3 = function(a) {
+	var aa = a * 6;
+	return [this.positions[aa], this.positions[aa + 1], this.positions[aa + 2]];
+};
+
+MeshLine.prototype.process = function() {
+	var l = this.positions.length / 6;
+
+	this.previous = [];
+	this.next = [];
+	this.side = [];
+	this.width = [];
+	this.indices_array = [];
+	this.uvs = [];
+
+	var w;
+
+	var v;
+	// initial previous points
+	if (this.compareV3(0, l - 1)) {
+		v = this.copyV3(l - 2);
+	} else {
+		v = this.copyV3(0);
+	}
+	this.previous.push(v[0], v[1], v[2]);
+	this.previous.push(v[0], v[1], v[2]);
+
+	for (var j = 0; j < l; j++) {
+		// sides
+		this.side.push(1);
+		this.side.push(-1);
+
+		// widths
+		if (this.widthCallback) w = this.widthCallback(j / (l - 1));
+		else w = 1;
+		this.width.push(w);
+		this.width.push(w);
+
+		// uvs
+		this.uvs.push(j / (l - 1), 0);
+		this.uvs.push(j / (l - 1), 1);
+
+		if (j < l - 1) {
+			// points previous to poisitions
+			v = this.copyV3(j);
+			this.previous.push(v[0], v[1], v[2]);
+			this.previous.push(v[0], v[1], v[2]);
+
+			// indices
+			var n = j * 2;
+			this.indices_array.push(n, n + 1, n + 2);
+			this.indices_array.push(n + 2, n + 1, n + 3);
+		}
+		if (j > 0) {
+			// points after poisitions
+			v = this.copyV3(j);
+			this.next.push(v[0], v[1], v[2]);
+			this.next.push(v[0], v[1], v[2]);
+		}
+	}
+
+	// last next point
+	if (this.compareV3(l - 1, 0)) {
+		v = this.copyV3(1);
+	} else {
+		v = this.copyV3(l - 1);
+	}
+	this.next.push(v[0], v[1], v[2]);
+	this.next.push(v[0], v[1], v[2]);
+
+	// redefining the attribute seems to prevent range errors
+	// if the user sets a differing number of vertices
+	if (!this._attributes || this._attributes.position.count !== this.positions.length) {
+		this._attributes = {
+			position: new THREE.BufferAttribute(new Float32Array(this.positions), 3),
+			previous: new THREE.BufferAttribute(new Float32Array(this.previous), 3),
+			next: new THREE.BufferAttribute(new Float32Array(this.next), 3),
+			side: new THREE.BufferAttribute(new Float32Array(this.side), 1),
+			width: new THREE.BufferAttribute(new Float32Array(this.width), 1),
+			uv: new THREE.BufferAttribute(new Float32Array(this.uvs), 2),
+			index: new THREE.BufferAttribute(new Uint16Array(this.indices_array), 1),
+			counters: new THREE.BufferAttribute(new Float32Array(this.counters), 1),
+		};
+	} else {
+		this._attributes.position.copyArray(new Float32Array(this.positions));
+		this._attributes.position.needsUpdate = true;
+		this._attributes.previous.copyArray(new Float32Array(this.previous));
+		this._attributes.previous.needsUpdate = true;
+		this._attributes.next.copyArray(new Float32Array(this.next));
+		this._attributes.next.needsUpdate = true;
+		this._attributes.side.copyArray(new Float32Array(this.side));
+		this._attributes.side.needsUpdate = true;
+		this._attributes.width.copyArray(new Float32Array(this.width));
+		this._attributes.width.needsUpdate = true;
+		this._attributes.uv.copyArray(new Float32Array(this.uvs));
+		this._attributes.uv.needsUpdate = true;
+		this._attributes.index.copyArray(new Uint16Array(this.indices_array));
+		this._attributes.index.needsUpdate = true;
+	}
+
+	this.setAttribute('position', this._attributes.position);
+	this.setAttribute('previous', this._attributes.previous);
+	this.setAttribute('next', this._attributes.next);
+	this.setAttribute('side', this._attributes.side);
+	this.setAttribute('width', this._attributes.width);
+	this.setAttribute('uv', this._attributes.uv);
+	this.setAttribute('counters', this._attributes.counters);
+
+	this.setIndex(this._attributes.index);
+
+	this.computeBoundingSphere();
+	this.computeBoundingBox();
+};
+
+function memcpy(src, srcOffset, dst, dstOffset, length) {
+	var i;
+
+	src = src.subarray || src.slice ? src : src.buffer;
+	dst = dst.subarray || dst.slice ? dst : dst.buffer;
+
+	src = srcOffset
+		? src.subarray
+			? src.subarray(srcOffset, length && srcOffset + length)
+			: src.slice(srcOffset, length && srcOffset + length)
+		: src;
+
+	if (dst.set) {
+		dst.set(src, dstOffset);
+	} else {
+		for (i = 0; i < src.length; i++) {
+			dst[i + dstOffset] = src[i];
+		}
+	}
+
+	return dst;
+}
+
+/**
+ * Fast method to advance the line by one position.  The oldest position is removed.
+ * @param position
+ */
+MeshLine.prototype.advance = function(position) {
+	var positions = this._attributes.position.array;
+	var previous = this._attributes.previous.array;
+	var next = this._attributes.next.array;
+	var l = positions.length;
+
+	// PREVIOUS
+	memcpy(positions, 0, previous, 0, l);
+
+	// POSITIONS
+	memcpy(positions, 6, positions, 0, l - 6);
+
+	positions[l - 6] = position.x;
+	positions[l - 5] = position.y;
+	positions[l - 4] = position.z;
+	positions[l - 3] = position.x;
+	positions[l - 2] = position.y;
+	positions[l - 1] = position.z;
+
+	// NEXT
+	memcpy(positions, 6, next, 0, l - 6);
+
+	next[l - 6] = position.x;
+	next[l - 5] = position.y;
+	next[l - 4] = position.z;
+	next[l - 3] = position.x;
+	next[l - 2] = position.y;
+	next[l - 1] = position.z;
+
+	this._attributes.position.needsUpdate = true;
+	this._attributes.previous.needsUpdate = true;
+	this._attributes.next.needsUpdate = true;
+};
+
+THREE.ShaderChunk['meshline_vert'] = [
+	'',
+	THREE.ShaderChunk.logdepthbuf_pars_vertex,
+	THREE.ShaderChunk.fog_pars_vertex,
+	'',
+	'attribute vec3 previous;',
+	'attribute vec3 next;',
+	'attribute float side;',
+	'attribute float width;',
+	'attribute float counters;',
+	'',
+	'uniform vec2 resolution;',
+	'uniform float lineWidth;',
+	'uniform vec3 color;',
+	'uniform float opacity;',
+	'uniform float sizeAttenuation;',
+	'',
+	'varying vec2 vUV;',
+	'varying vec4 vColor;',
+	'varying float vCounters;',
+	'',
+	'vec2 fix( vec4 i, float aspect ) {',
+	'',
+	'    vec2 res = i.xy / i.w;',
+	'    res.x *= aspect;',
+	'	 vCounters = counters;',
+	'    return res;',
+	'',
+	'}',
+	'',
+	'void main() {',
+	'',
+	'    float aspect = resolution.x / resolution.y;',
+	'',
+	'    vColor = vec4( color, opacity );',
+	'    vUV = uv;',
+	'',
+	'    mat4 m = projectionMatrix * modelViewMatrix;',
+	'    vec4 finalPosition = m * vec4( position, 1.0 );',
+	'    vec4 prevPos = m * vec4( previous, 1.0 );',
+	'    vec4 nextPos = m * vec4( next, 1.0 );',
+	'',
+	'    vec2 currentP = fix( finalPosition, aspect );',
+	'    vec2 prevP = fix( prevPos, aspect );',
+	'    vec2 nextP = fix( nextPos, aspect );',
+	'',
+	'    float w = lineWidth * width;',
+	'',
+	'    vec2 dir;',
+	'    if( nextP == currentP ) dir = normalize( currentP - prevP );',
+	'    else if( prevP == currentP ) dir = normalize( nextP - currentP );',
+	'    else {',
+	'        vec2 dir1 = normalize( currentP - prevP );',
+	'        vec2 dir2 = normalize( nextP - currentP );',
+	'        dir = normalize( dir1 + dir2 );',
+	'',
+	'        vec2 perp = vec2( -dir1.y, dir1.x );',
+	'        vec2 miter = vec2( -dir.y, dir.x );',
+	'        //w = clamp( w / dot( miter, perp ), 0., 4. * lineWidth * width );',
+	'',
+	'    }',
+	'',
+	'    //vec2 normal = ( cross( vec3( dir, 0. ), vec3( 0., 0., 1. ) ) ).xy;',
+	'    vec4 normal = vec4( -dir.y, dir.x, 0., 1. );',
+	'    normal.xy *= .5 * w;',
+	'    normal *= projectionMatrix;',
+	'    if( sizeAttenuation == 0. ) {',
+	'        normal.xy *= finalPosition.w;',
+	'        normal.xy /= ( vec4( resolution, 0., 1. ) * projectionMatrix ).xy;',
+	'    }',
+	'',
+	'    finalPosition.xy += normal.xy * side;',
+	'',
+	'    gl_Position = finalPosition;',
+	'',
+	THREE.ShaderChunk.logdepthbuf_vertex,
+	THREE.ShaderChunk.fog_vertex && '    vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );',
+	THREE.ShaderChunk.fog_vertex,
+	'}',
+].join('\n');
+
+THREE.ShaderChunk['meshline_frag'] = [
+	'',
+	THREE.ShaderChunk.fog_pars_fragment,
+	THREE.ShaderChunk.logdepthbuf_pars_fragment,
+	'',
+	'uniform sampler2D map;',
+	'uniform sampler2D alphaMap;',
+	'uniform float useMap;',
+	'uniform float useAlphaMap;',
+	'uniform float useDash;',
+	'uniform float dashArray;',
+	'uniform float dashOffset;',
+	'uniform float dashRatio;',
+	'uniform float visibility;',
+	'uniform float alphaTest;',
+	'uniform vec2 repeat;',
+	'uniform vec2 uOffset;',
+	'',
+	'varying vec2 vUV;',
+	'varying vec4 vColor;',
+	'varying float vCounters;',
+	'',
+	'void main() {',
+	'',
+	THREE.ShaderChunk.logdepthbuf_fragment,
+	'',
+	'    vec4 c = vColor;',
+	'    if( useMap == 1. ) c *= texture2D( map, vUV * repeat + uOffset );',
+	'    if( useAlphaMap == 1. ) c.a *= texture2D( alphaMap, vUV * repeat ).a;',
+	'    if( c.a < alphaTest ) discard;',
+	'    if( useDash == 1. ){',
+	'        c.a *= ceil(mod(vCounters + dashOffset, dashArray) - (dashArray * dashRatio));',
+	'    }',
+	'    gl_FragColor = c;',
+	'    gl_FragColor.a *= step(vCounters, visibility);',
+	'',
+	THREE.ShaderChunk.fog_fragment,
+	'}',
+].join('\n');
+
+export class MeshLineMaterial extends THREE.ShaderMaterial {
+	constructor(parameters) {
+		super({
+			uniforms: Object.assign({}, THREE.UniformsLib.fog, {
+				lineWidth: {value: 1},
+				map: {value: null},
+				useMap: {value: 0},
+				alphaMap: {value: null},
+				useAlphaMap: {value: 0},
+				color: {value: new THREE.Color(0xffffff)},
+				opacity: {value: 1},
+				resolution: {value: new THREE.Vector2(1, 1)},
+				sizeAttenuation: {value: 1},
+				dashArray: {value: 0},
+				dashOffset: {value: 0},
+				dashRatio: {value: 0.5},
+				useDash: {value: 0},
+				visibility: {value: 1},
+				alphaTest: {value: 0},
+				repeat: {value: new THREE.Vector2(1, 1)},
+				uOffset: {value: new THREE.Vector2(0, 0)},
+			}),
+
+			vertexShader: THREE.ShaderChunk.meshline_vert,
+
+			fragmentShader: THREE.ShaderChunk.meshline_frag,
+		});
+		this.isMeshLineMaterial = true;
+		this.type = 'MeshLineMaterial';
+
+		Object.defineProperties(this, {
+			lineWidth: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.lineWidth.value;
+				},
+				set: function(value) {
+					this.uniforms.lineWidth.value = value;
+				},
+			},
+			map: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.map.value;
+				},
+				set: function(value) {
+					this.uniforms.map.value = value;
+				},
+			},
+			useMap: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.useMap.value;
+				},
+				set: function(value) {
+					this.uniforms.useMap.value = value;
+				},
+			},
+			alphaMap: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.alphaMap.value;
+				},
+				set: function(value) {
+					this.uniforms.alphaMap.value = value;
+				},
+			},
+			useAlphaMap: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.useAlphaMap.value;
+				},
+				set: function(value) {
+					this.uniforms.useAlphaMap.value = value;
+				},
+			},
+			color: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.color.value;
+				},
+				set: function(value) {
+					this.uniforms.color.value = value;
+				},
+			},
+			opacity: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.opacity.value;
+				},
+				set: function(value) {
+					this.uniforms.opacity.value = value;
+				},
+			},
+			resolution: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.resolution.value;
+				},
+				set: function(value) {
+					this.uniforms.resolution.value.copy(value);
+				},
+			},
+			sizeAttenuation: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.sizeAttenuation.value;
+				},
+				set: function(value) {
+					this.uniforms.sizeAttenuation.value = value;
+				},
+			},
+			dashArray: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.dashArray.value;
+				},
+				set: function(value) {
+					this.uniforms.dashArray.value = value;
+					this.useDash = value !== 0 ? 1 : 0;
+				},
+			},
+			dashOffset: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.dashOffset.value;
+				},
+				set: function(value) {
+					this.uniforms.dashOffset.value = value;
+				},
+			},
+			dashRatio: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.dashRatio.value;
+				},
+				set: function(value) {
+					this.uniforms.dashRatio.value = value;
+				},
+			},
+			useDash: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.useDash.value;
+				},
+				set: function(value) {
+					this.uniforms.useDash.value = value;
+				},
+			},
+			visibility: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.visibility.value;
+				},
+				set: function(value) {
+					this.uniforms.visibility.value = value;
+				},
+			},
+			alphaTest: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.alphaTest.value;
+				},
+				set: function(value) {
+					this.uniforms.alphaTest.value = value;
+				},
+			},
+			repeat: {
+				enumerable: true,
+				get: function() {
+					return this.uniforms.repeat.value;
+				},
+				set: function(value) {
+					this.uniforms.repeat.value.copy(value);
+				},
+			},
+		});
+
+		this.setValues(parameters);
+	}
+}
+
+MeshLineMaterial.prototype.copy = function(source) {
+	THREE.ShaderMaterial.prototype.copy.call(this, source);
+
+	this.lineWidth = source.lineWidth;
+	this.map = source.map;
+	this.useMap = source.useMap;
+	this.alphaMap = source.alphaMap;
+	this.useAlphaMap = source.useAlphaMap;
+	this.color.copy(source.color);
+	this.opacity = source.opacity;
+	this.resolution.copy(source.resolution);
+	this.sizeAttenuation = source.sizeAttenuation;
+	this.dashArray.copy(source.dashArray);
+	this.dashOffset.copy(source.dashOffset);
+	this.dashRatio.copy(source.dashRatio);
+	this.useDash = source.useDash;
+	this.visibility = source.visibility;
+	this.alphaTest = source.alphaTest;
+	this.repeat.copy(source.repeat);
+
+	return this;
+};

+ 20 - 0
src/hooks/Utils.ts

@@ -0,0 +1,20 @@
+import type {Vector3, BufferGeometry, Mesh, MeshBasicMaterial} from "three";
+import type {useFloorConnectFinder} from "@/views/threeMap/components/PathFinder/useFloorConnectFinder.ts";
+
+export const formatCoordinate = (v: Vector3) => {
+	return `x:${v.x.toFixed(1)},y:${v.y.toFixed(1)},z:${v.z.toFixed(1)}`;
+};
+
+export const isMesh = (obj: unknown): obj is Mesh<BufferGeometry, MeshBasicMaterial> => {
+	return typeof obj === "object" && obj !== null && "isMesh" in obj;
+};
+
+export const getRandomIntInclusive = (min: number, max: number) => {
+	min = Math.ceil(min);
+	max = Math.floor(max);
+	return Math.floor(Math.random() * (max - min + 1)) + min; // 含最大值,含最小值
+};
+
+export const isMultiFloorNavPath = (path: unknown): path is ReturnType<ReturnType<typeof useFloorConnectFinder>["findNavPath"]> => {
+	return !!(typeof path === "object" && path !== null && Array.isArray(path));
+};

+ 7 - 0
src/hooks/useGLTFLoader.ts

@@ -0,0 +1,7 @@
+import {type GLTF, GLTFLoader} from "three/examples/jsm/loaders/GLTFLoader";
+
+const gltf_loader = new GLTFLoader();
+
+export const useGLTFLoader = async (url: string): Promise<GLTF> => {
+	return await gltf_loader.loadAsync(url);
+};

+ 243 - 0
src/hooks/useMesh.ts

@@ -0,0 +1,243 @@
+import {
+	BufferGeometry,
+	CanvasTexture,
+	CylinderGeometry,
+	DoubleSide,
+	Mesh,
+	MeshBasicMaterial,
+	RepeatWrapping,
+	Scene,
+	SphereGeometry,
+	Sprite,
+	SpriteMaterial, StaticDrawUsage,
+	Texture, Vector2,
+	Vector3
+} from "three";
+// import {Line2} from "three/examples/jsm/lines/Line2";
+// import {LineGeometry} from "three/examples/jsm/lines/LineGeometry";
+// import {LineMaterial} from "three/examples/jsm/lines/LineMaterial";
+// @ts-ignore
+// import {MeshLine, MeshLineMaterial} from "../hooks/MeshLine";
+// @ts-ignore
+import {PathPointList, PathGeometry} from "three.path";
+import type {ColorRepresentation} from "three/src/utils";
+import {CSS2DObject} from "three/examples/jsm/renderers/CSS2DRenderer";
+import type {WeightedGraph} from "@/views/threeMap/components/PathFinder/WeightedGraph.ts";
+
+export const createSphere = (radius: number, color: ColorRepresentation, position: Vector3) => {
+	const sphere = new Mesh(
+		new SphereGeometry(radius),
+		new MeshBasicMaterial({color})
+	);
+	sphere.position.copy(position);
+	sphere.name = "sphere";
+
+	return sphere;
+};
+
+export const createSprite = (text: string, position: Vector3, offsetY: number, transparent_bg = false) => {
+	const canvas = document.createElement("canvas");
+	canvas.width = 2048;
+	canvas.height = 1024;
+
+	const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+	context.fillStyle = `rgba(255, 255, 255, ${transparent_bg ? 0 : 0.9})`;
+	context.fillRect(0, 256, 2048, 512);
+	context.textAlign = "center";
+	context.textBaseline = "middle";
+	context.font = "bold 250px sans-serif";
+	context.fillStyle = "rgba(230, 115, 0, 1)";
+	context.fillText(text, canvas.width / 2, canvas.height / 2);
+
+	const texture = new CanvasTexture(canvas);
+
+	const material = new SpriteMaterial({
+		map: texture,
+	});
+
+	const sprite = new Sprite(material);
+	sprite.position.copy(position);
+	sprite.name = "sprite";
+	sprite.translateY(offsetY);
+
+	return sprite;
+};
+
+export const createImgSprite = (texture: Texture, position: Vector3, offsetY: number, scale: number = 1) => {
+	const material = new SpriteMaterial({
+		map: texture,
+		sizeAttenuation: false, // 不会被相机深度所衰减
+		depthTest: false // 关闭深度测试,不会被其他mesh遮挡
+	});
+
+	const sprite = new Sprite(material);
+	sprite.position.copy(position);
+	sprite.name = "sprite";
+	sprite.translateY(offsetY);
+
+	sprite.scale.set(scale, scale, scale);
+	return sprite;
+};
+
+export const createCylinder = (position: Vector3, cycle_light_texture: Texture) => {
+	const cylinder = new Mesh(
+		new CylinderGeometry(0.5, 0.5, 1, 32, 32, true),
+		new MeshBasicMaterial({map: cycle_light_texture, side: DoubleSide, transparent: true, opacity: 0.5})
+	);
+	cylinder.position.copy(position);
+	cylinder.translateY(0.5);
+
+	return cylinder;
+};
+
+export const createCSSLabel = (text: string, position: Vector3, offsetY: number) => {
+	const div = document.createElement("div");
+	div.className = "css-label";
+	div.innerHTML = text;
+	div.style.cssText = `
+		padding: 4px 10px;
+		text-align: center;
+		background: rgba(0, 0, 0, 0.4);
+		color: #eee;
+	`;
+
+	const css_label = new CSS2DObject(div);
+	css_label.position.copy(position);
+	css_label.translateY(offsetY);
+
+	return css_label;
+};
+
+export const createLine = (line_arr: Vector3[], track_width: number, track_color: number | string, offset_y: number = 1, texture: Texture) => {
+	// const line_geometry = new LineGeometry();
+	//
+	// line_geometry.setPositions(line_arr);
+	//
+	// const line_material = new LineMaterial({
+	// 	color: track_color ? new Color(track_color).getHex() : new Color(Math.random(), Math.random(), Math.random()).getHex(),
+	// 	linewidth: track_width,
+	// });
+	// line_material.worldUnits = true; // 防止缩小后的线条尺寸问题
+	// line_material.resolution.set(window.innerWidth, window.innerHeight);
+	// const line = new Line2(line_geometry, line_material);
+	// line.computeLineDistances();
+	// line.position.y += offset_y;
+	// return line;
+
+	/* const geometry = new BufferGeometry().setFromPoints(line_arr);
+	const line = new MeshLine();
+	line.setGeometry(geometry);
+
+	texture.wrapS = RepeatWrapping;
+	texture.repeat.set(line_arr.length * 8, 1);
+	const material = new MeshLineMaterial({
+		// color: 0xff9900,
+		lineWidth: 0.4,
+		map: texture,
+		useMap: 1
+	});
+	material.uniforms.repeat.value = new Vector2(line_arr.length * 8, 1);
+	const line_mesh = new Mesh(line, material);
+	line_mesh.position.y += offset_y;*/
+
+	texture.wrapS = texture.wrapT = RepeatWrapping;
+	const list = new PathPointList();
+	list.set(line_arr, 0.5, 10, undefined, false);
+
+	// Init by data
+	const geometry = new PathGeometry({
+		pathPointList: list,
+		options: {
+			width: 0.25, // default is 0.1
+			arrow: false, // default is true
+			progress: 1, // default is 1
+			side: "both" // "left"/"right"/"both", default is "both"
+		},
+		usage: StaticDrawUsage // geometry usage
+	}, false);
+
+	const line_mesh = new Mesh(
+		geometry,
+		new MeshBasicMaterial({
+			map: texture,
+			// color: 0xff9900,
+			depthTest: true,
+			depthWrite: false
+		})
+	);
+	line_mesh.position.y += 0.05;
+
+	return line_mesh;
+};
+
+export const useConnectPoint = (scene: Scene) => {
+	// 当前地图所有的连通点指引元素
+	const cur_connect_points_labels: ReturnType<typeof createCSSLabel>[] = [];
+	const cur_connect_points_cylinder: ReturnType<typeof createCylinder>[] = [];
+
+	// 移除上一轮的连通点物体
+	const removeConnectPoint = () => {
+		if (cur_connect_points_labels.length) {
+			cur_connect_points_labels.forEach(label => {
+				scene.remove(label);
+			});
+			cur_connect_points_cylinder.forEach(cylinder => {
+				cylinder.material.dispose();
+				scene.remove(cylinder);
+			});
+
+			cur_connect_points_labels.length = 0;
+			cur_connect_points_cylinder.length = 0;
+		}
+	};
+
+	/**
+	 * 渲染当前楼层地图连通点物体
+	 * @param floor_conn_points 当前楼层的全部节点
+	 * @param adj_list 图的全部邻接点
+	 * @param cycle_light_texture 连通点贴图
+	 */
+	const renderConnectPoint = (floor_conn_points: Map<string, Vector3>, adj_list: ReturnType<WeightedGraph["getAdjList"]>, cycle_light_texture: Texture) => {
+		removeConnectPoint();
+
+		const connect_map = getConnectMap(adj_list);
+
+		if (!floor_conn_points) return;
+
+		floor_conn_points.forEach((point, point_key) => {
+			const label = createCSSLabel(`
+			<span style="font-weight: bold;">${point_key}</span>
+			<br>
+			连通点:${connect_map[point_key]}
+			`,
+			point,
+			1.3
+			);
+			scene.add(label);
+			cur_connect_points_labels.push(label);
+
+			const cylinder = createCylinder(point, cycle_light_texture);
+			scene.add(cylinder);
+			cur_connect_points_cylinder.push(cylinder);
+		});
+	};
+
+	// 获得所有节点相邻节点映射
+	const getConnectMap = (adj_list: ReturnType<WeightedGraph["getAdjList"]>) => {
+		let map: Record<string, string> = {};
+
+		for (const [key, neighbors] of adj_list.entries()) {
+			map[key] = "";
+			for (const [neighbor_key, weight] of neighbors.entries()) {
+				map[key] += `<p>${neighbor_key},距离:${weight}<p />`;
+			}
+		}
+
+		return map;
+	};
+
+	return {
+		removeConnectPoint,
+		renderConnectPoint
+	};
+};

+ 8 - 0
src/main.ts

@@ -0,0 +1,8 @@
+import { createApp } from 'vue'
+import 'vant/es/toast/style'
+import './style.css'
+import App from './App.vue'
+import { router } from './router'
+const app = createApp(App)
+app.use(router)
+app.mount('#app')

+ 20 - 0
src/router/index.ts

@@ -0,0 +1,20 @@
+import { createRouter, createWebHashHistory } from "vue-router";
+import type { RouteRecordRaw } from 'vue-router'
+
+const routes = [
+    {
+        path: '/',
+        name: 'root',
+        component: () => import('@/views/threeMap/index.vue')
+    },
+    {
+        path: '/map',
+        name: 'map',
+        component: () => import('@/views/home/map.vue')
+    },
+] as RouteRecordRaw[]
+
+export const router = createRouter({
+    history: createWebHashHistory(),
+    routes,
+})

+ 24 - 0
src/style.css

@@ -0,0 +1,24 @@
+:root {
+  --color-bg-gray: rgb(238, 238, 238);
+  
+  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-text-size-adjust: 100%;
+}
+body {
+  margin: 0;
+  padding: 0;
+  font-size: 14px;
+}
+.full {
+  width: 100vw;
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+}

+ 39 - 0
src/utils/EventEmitter.ts

@@ -0,0 +1,39 @@
+export type EventType = string;
+
+export interface EventCallBack {
+	(parameters: any): any;
+}
+
+export class EventEmitter {
+	private listeners: Map<EventType, EventCallBack[]>;
+
+	constructor() {
+		this.listeners = new Map<EventType, EventCallBack[]>();
+	}
+
+	addEventListener(type: EventType, callback: EventCallBack) {
+		const origin_callback = this.listeners.get(type);
+		if (origin_callback === undefined) {
+			this.listeners.set(type, [callback]);
+		} else {
+			origin_callback.push(callback);
+		}
+	}
+
+	removeEventListener(type: EventType, callback: EventCallBack) {
+		const origin_callback = this.listeners.get(type);
+		if (origin_callback !== undefined) {
+			const callback_index = origin_callback.findIndex((value) => value === callback);
+			if (callback_index !== -1) {
+				origin_callback.splice(callback_index, 1);
+			}
+		}
+	}
+
+	dispatchEvent(type: EventType, parameters?: any) {
+		const origin_callback = this.listeners.get(type);
+		if (origin_callback !== undefined) {
+			origin_callback.forEach((callback) => callback(parameters));
+		}
+	}
+}

+ 90 - 0
src/views/home/HomeIndex.vue

@@ -0,0 +1,90 @@
+<template>
+    <div class="map" id="map"></div>
+</template>
+<script setup lang="ts">
+import { onMounted } from 'vue'
+import { Scene, PointLayer } from '@antv/l7';
+import { GaodeMap, Map, Mapbox } from '@antv/l7-maps';
+import { ThreeLayer, ThreeRender } from '@antv/l7-three';
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
+const raycaster = new THREE.Raycaster();
+const mouse = new THREE.Vector2();
+
+onMounted(() => {
+    const scene = new Scene({
+        id: 'map',
+        logoVisible: false,
+        map: new GaodeMap({
+            center: [113, 29],
+            pitch: 70,
+            zoom: 12,
+            rotation: 170,
+            style: 'blank',
+        }),
+    });
+    scene.value = scene
+    scene.on('loaded', () => {
+        scene.registerRenderService(ThreeRender);
+        const threeJSLayer = new ThreeLayer({
+            enableMultiPassRenderer: false,
+            onAddMeshes: (threeScene, layer) => {
+                const center = scene.getCenter();
+                threeScene.add(new THREE.AmbientLight(0xffffff));
+                const sunlight = new THREE.DirectionalLight(0xffffff, 0.25);
+                sunlight.position.set(0, 80000000, 100000000);
+                sunlight.matrixWorldNeedsUpdate = true;
+                threeScene.add(sunlight);
+                const loader = new GLTFLoader();
+                loader.load(
+                    '/models/scene.glb',
+                    (gltf) => {
+                        const gltfScene = gltf.scene;
+                        layer.adjustMeshToMap(gltfScene);
+                        layer.setMeshScale(gltfScene, 100, 100, 100);
+                        layer.setObjectLngLat(gltfScene, [center.lng, center.lat], 0);
+                        // 向场景中添加模型
+                        threeScene.add(gltfScene);
+                        // 重绘图层
+                        layer.render();
+                        const onMouseClick = (event) => {
+                            // 计算鼠标点击位置
+                            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
+                            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
+                            // 更新射线投射方向
+                            raycaster.setFromCamera(mouse, layer.getRenderCamera());
+                            // 计算射线投射的物体交点
+                            const intersects = raycaster.intersectObjects(threeScene.children, true);
+                            if (intersects.length > 0) {
+                                // 点击到模型时的处理
+                                const selectedObject = intersects[0].object;
+                                console.log(selectedObject);
+                                // 检查点击的对象是否为模型
+                                if (selectedObject.name) {
+                                    if (selectedObject.parent.position.z === 0) {
+                                        selectedObject.parent.position.z += 10
+                                    } else {
+                                        selectedObject.parent.position.z -= 10
+                                    }
+                                }
+
+                            }
+                        }
+                        window.addEventListener('click', onMouseClick, true);
+                    },
+                );
+            },
+        }).animate(true);
+        scene.addLayer(threeJSLayer);
+        // 定义点击事件
+    });
+})
+
+
+</script>
+<style lang="scss" scoped>
+.map {
+    height: 100vh;
+    width: 100vw;
+}
+</style>

+ 354 - 0
src/views/home/index.vue

@@ -0,0 +1,354 @@
+<template>
+    <div class="container">
+        <div ref="canvasContainer" class="canvas-container"></div>
+        <div class="controls">
+            <button @click="switchCameraMode">{{ is2DView ? '切换到3D' : '切换到2D' }}</button>
+            <button @click="clearAll">清除路径</button>
+            <div class="status">{{ statusText }}</div>
+            <div v-if="loading" class="loading">模型加载中...</div>
+        </div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onBeforeUnmount, toRaw } from 'vue';
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import PF from 'pathfinding';
+
+// 场景对象
+const canvasContainer = ref<HTMLElement | null>(null);
+let scene: THREE.Scene;
+let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
+let renderer: THREE.WebGLRenderer;
+let controls: OrbitControls;
+let model: THREE.Group;
+
+// 路径规划系统
+const pathfinding = new (class {
+    grid!: PF.Grid;
+    finder: PF.AStarFinder;
+    cellSize = 0.5;  // 网格精度(单位:米)
+    worldSize = 50;  // 场景尺寸(单位:米)
+
+    constructor() {
+        this.finder = new PF.AStarFinder({
+            diagonalMovement: PF.DiagonalMovement.Always
+        });
+    }
+
+    init(obstacles: THREE.Object3D[]) {
+        const matrix = this.createGridMatrix();
+        this.updateGrid(matrix, obstacles);
+        this.grid = new PF.Grid(matrix);
+    }
+
+    private createGridMatrix() {
+        const cells = Math.ceil(this.worldSize / this.cellSize);
+        return Array(cells).fill(0).map(() => Array(cells).fill(0));
+    }
+
+    private updateGrid(matrix: number[][], obstacles: THREE.Object3D[]) {
+        obstacles.forEach(obj => {
+            const bbox = new THREE.Box3().setFromObject(obj);
+            const min = this.worldToGrid(bbox.min);
+            const max = this.worldToGrid(bbox.max);
+
+            for (let x = min.x; x <= max.x; x++) {
+                for (let y = min.y; y <= max.y; y++) {
+                    if (x >= 0 && x < matrix.length && y >= 0 && y < matrix[0].length) {
+                        matrix[y][x] = 1;  // 注意此处改为[y][x]
+                    }
+                }
+            }
+        });
+    }
+
+    private worldToGrid(pos: THREE.Vector3) {
+        return {
+            x: Math.floor((pos.x + this.worldSize / 2) / this.cellSize),
+            y: Math.floor((pos.y + this.worldSize / 2) / this.cellSize)  // 使用Y轴坐标
+        };
+    }
+
+    findPath(start: THREE.Vector3, end: THREE.Vector3) {
+        const gridStart = this.worldToGrid(start);
+        const gridEnd = this.worldToGrid(end);
+
+        const path = this.finder.findPath(
+            gridStart.x, gridStart.y,  // 使用Y轴坐标
+            gridEnd.x, gridEnd.y,
+            this.grid.clone()
+        );
+
+        return path.map(p => [
+            (p[0] * this.cellSize) - this.worldSize / 2 + this.cellSize / 2,
+            (p[1] * this.cellSize) - this.worldSize / 2 + this.cellSize / 2,
+            0.5  // Z轴固定高度
+        ]);
+    }
+})();
+
+// 状态控制
+const is2DView = ref(false);
+const loading = ref(true);
+const isSettingStart = ref(true);
+const startPoint = ref<THREE.Vector3 | null>(null);
+const endPoint = ref<THREE.Vector3 | null>(null);
+const pathLine = ref<THREE.Line | null>(null);
+const markers = ref<THREE.Mesh[]>([]);
+
+// 状态显示文本
+const statusText = computed(() => {
+    if (!startPoint.value) return '点击场景设置起点';
+    if (!endPoint.value) return '点击场景设置终点';
+    return '路径规划完成';
+});
+
+// 初始化场景
+const initScene = () => {
+    scene = new THREE.Scene();
+    scene.background = new THREE.Color(0xf0f0f0);
+
+    // 光照设置
+    const ambientLight = new THREE.AmbientLight(0xffffff, 0.1);
+    scene.add(ambientLight);
+    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
+    directionalLight.position.set(5, 5, 5);
+    scene.add(directionalLight);
+
+    // 渲染器
+    renderer = new THREE.WebGLRenderer({ antialias: true });
+    renderer.setSize(window.innerWidth, window.innerHeight);
+    canvasContainer.value?.appendChild(renderer.domElement);
+
+    // 默认相机
+    camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 1000);
+    camera.position.set(50, 50, 50);
+    camera.lookAt(0, 0, 0);
+};
+
+// 加载模型
+const loadModel = async () => {
+    const loader = new GLTFLoader();
+    try {
+        const gltf = await loader.loadAsync('/models/scene.glb');
+        model = gltf.scene;
+
+        // 模型居中
+        const box = new THREE.Box3().setFromObject(model);
+        const center = box.getCenter(new THREE.Vector3());
+        model.position.sub(center);
+
+        // 识别障碍物(名称包含obstacle的对象)
+        const obstacles = model.children.filter(obj => obj.name.includes('obstacle'));
+        obstacles.forEach(obj => {
+            obj.material = new THREE.MeshBasicMaterial({
+                color: 0xff0000,
+                wireframe: true
+            });
+        });
+
+        scene.add(model);
+        pathfinding.init(obstacles);
+        loading.value = false;
+    } catch (error) {
+        console.error('模型加载失败:', error);
+        loading.value = false;
+    }
+};
+
+// 控制器初始化
+const initControls = () => {
+    controls = new OrbitControls(camera, renderer.domElement);
+    controls.enableDamping = true;
+    controls.dampingFactor = 0.05;
+    controls.enableRotate = false;
+};
+
+// 视角切换逻辑
+const set2DView = () => {
+    const box = new THREE.Box3().setFromObject(model);
+    const size = box.getSize(new THREE.Vector3());
+
+    camera = new THREE.OrthographicCamera(
+        -size.x * 0.6,
+        size.x * 0.6,
+        size.y * 0.6,
+        -size.y * 0.6,
+        0.1,
+        size.z * 2
+    );
+
+    camera.position.set(0, 0, size.z * 1.5);
+    camera.lookAt(box.getCenter(new THREE.Vector3()));
+    controls.target.copy(box.getCenter(new THREE.Vector3()));
+    controls.update();
+};
+
+const set3DView = () => {
+    camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 1000);
+    camera.position.set(50, 50, 50);
+    camera.lookAt(0, 0, 0);
+    controls.enableRotate = true;
+    controls.update();
+};
+
+const switchCameraMode = () => {
+    is2DView.value = !is2DView.value;
+    is2DView.value ? set2DView() : set3DView();
+};
+
+// 路径规划功能
+const handleClick = (event: MouseEvent) => {
+    const raycaster = new THREE.Raycaster();
+    const mouse = new THREE.Vector2(
+        (event.clientX / window.innerWidth) * 2 - 1,
+        -(event.clientY / window.innerHeight) * 2 + 1
+    );
+
+    raycaster.setFromCamera(mouse, camera);
+    const intersects = raycaster.intersectObjects(scene.children);
+
+    if (intersects.length > 0) {
+        const point = intersects[0].point;
+
+        if (isSettingStart.value) {
+            startPoint.value = point.clone();
+        } else {
+            endPoint.value = point.clone();
+            calculatePath();
+        }
+        updateMarkers(point);
+        isSettingStart.value = !isSettingStart.value;
+    }
+};
+
+const calculatePath = () => {
+    if (!startPoint.value || !endPoint.value) return;
+
+    const rawPath = pathfinding.findPath(startPoint.value, endPoint.value);
+    const pathPoints = rawPath.map(p => new THREE.Vector3(p[0], p[1], p[2]));
+
+    if (pathLine.value) scene.remove(toRaw(pathLine.value));
+
+    const geometry = new THREE.BufferGeometry().setFromPoints(pathPoints);
+    const material = new THREE.LineBasicMaterial({ color: 0xffff00, linewidth: 2 });
+    pathLine.value = new THREE.Line(geometry, material);
+    scene.add(toRaw(pathLine.value));
+};
+
+const updateMarkers = (position: THREE.Vector3) => {
+    const color = isSettingStart.value ? 0x00ff00 : 0xff0000;
+    const marker = new THREE.Mesh(
+        new THREE.SphereGeometry(0.5),
+        new THREE.MeshBasicMaterial({ color })
+    );
+    marker.position.copy(position);
+    scene.add(marker);
+    markers.value.push(marker);
+};
+
+const clearAll = () => {
+    startPoint.value = null;
+    endPoint.value = null;
+    isSettingStart.value = true;
+    markers.value.forEach((m:any) => { 
+        scene.remove(toRaw(m))
+    });
+    markers.value = [];
+    if (pathLine.value) scene.remove(toRaw(pathLine.value));
+};
+
+// 窗口响应
+const onWindowResize = () => {
+    const aspect = window.innerWidth / window.innerHeight;
+
+    if (camera instanceof THREE.PerspectiveCamera) {
+        camera.aspect = aspect;
+    } else {
+        const zoom = 1;
+        camera.left = -zoom * aspect;
+        camera.right = zoom * aspect;
+        camera.top = zoom;
+        camera.bottom = -zoom;
+    }
+
+    camera.updateProjectionMatrix();
+    renderer.setSize(window.innerWidth, window.innerHeight);
+};
+
+// 生命周期
+onMounted(async () => {
+    initScene();
+    await loadModel();
+    initControls();
+    renderer.domElement.addEventListener('click', handleClick);
+    animate();
+    window.addEventListener('resize', onWindowResize);
+});
+
+onBeforeUnmount(() => {
+    renderer.domElement.removeEventListener('click', handleClick);
+    window.removeEventListener('resize', onWindowResize);
+    renderer.dispose();
+    controls.dispose();
+});
+
+const animate = () => {
+    requestAnimationFrame(animate);
+    controls.update();
+    renderer.render(scene, camera);
+};
+</script>
+
+<style scoped>
+/* 保持原有样式不变 */
+.container {
+    position: relative;
+    width: 100vw;
+    height: 100vh;
+    overflow: hidden;
+}
+
+.canvas-container {
+    width: 100%;
+    height: 100%;
+}
+
+.controls {
+    position: absolute;
+    top: 20px;
+    left: 20px;
+    background: rgba(0, 0, 0, 0.7);
+    padding: 15px;
+    border-radius: 8px;
+    color: white;
+    z-index: 100;
+}
+
+button {
+    padding: 8px 16px;
+    background: #2196F3;
+    border: none;
+    color: white;
+    cursor: pointer;
+    border-radius: 4px;
+    margin-right: 10px;
+}
+
+button:hover {
+    background: #1976D2;
+}
+
+.status {
+    margin-top: 10px;
+    font-size: 14px;
+}
+
+.loading {
+    color: #ff9800;
+    margin-top: 10px;
+}
+</style>

+ 513 - 0
src/views/home/map.vue

@@ -0,0 +1,513 @@
+<template>
+    <div class="container">
+        <div ref="canvasContainer" class="canvas-container"></div>
+        <div class="controls">
+            <button @click="switchCameraMode">
+                {{ is2DView ? '切换到3D视图' : '切换到平面视图' }}
+            </button>
+            <button @click="clearAll">🧹 清除路径</button>
+            <div class="status">
+                <span v-if="pathDistance">{{ statusText }}</span>
+                <span v-else>🖱️ {{ helpText }}</span>
+            </div>
+            <div v-if="loading" class="loading">🔄 机场模型加载中...</div>
+        </div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onBeforeUnmount, toRaw } from 'vue';
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import PF from 'pathfinding';
+
+// 场景对象
+const canvasContainer = ref<HTMLElement | null>(null);
+const scene = new THREE.Scene();
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+const camera = ref<THREE.Camera>(new THREE.PerspectiveCamera());
+const controls = ref<OrbitControls | null>(null);
+const model = ref<THREE.Object3D | null>(null);
+
+// 路径系统
+const CELL_SIZE = 0.5;
+let grid: PF.Grid;
+let pathfinder: PF.AStarFinder;
+const modelBBox = new THREE.Box3();
+
+// 状态控制
+const is2DView = ref(false);
+const loading = ref(true);
+const startPoint = ref<THREE.Vector3 | null>(null);
+const endPoint = ref<THREE.Vector3 | null>(null);
+const pathLine = ref<THREE.Line | null>(null);
+const markers: THREE.Mesh[] = [];
+const pathDistance = ref(0);
+
+// 文字提示
+const helpText = computed(() =>
+    is2DView.value
+        ? '点击平面图设置起点和终点'
+        : '3D模式下请切换至2D进行路径规划'
+);
+
+const statusText = computed(() =>
+    `🗺️ 路径距离: ${pathDistance.value.toFixed(1)}米 | 🚶 预计步行${Math.round(pathDistance.value / 1.4)}秒`
+);
+
+const initScene = () => {
+    scene.background = new THREE.Color(0xf0f4f8);
+    renderer.setPixelRatio(window.devicePixelRatio);
+    renderer.setSize(window.innerWidth, window.innerHeight);
+
+    const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
+    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6);
+    directionalLight.position.set(0, 0, 100);
+    scene.add(ambientLight, directionalLight);
+
+    camera.value = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 1000);
+    camera.value.position.set(50, 50, 50);
+    camera.value.lookAt(0, 0, 0);
+};
+
+const loadAirportModel = async () => {
+    const loader = new GLTFLoader();
+    try {
+        const gltf = await loader.loadAsync('/models/scene.glb');
+        model.value = gltf.scene;
+        
+        // 标记可点击地面
+        model.value.traverse(child => {
+            if (child.isMesh && child.name.toLowerCase().includes('Ground')) {
+                child.userData.isGround = true;
+            }
+        });
+
+        modelBBox.setFromObject(toRaw(model.value));
+        const width = modelBBox.max.x - modelBBox.min.x;
+        const height = modelBBox.max.y - modelBBox.min.y;
+
+        grid = new PF.Grid(
+            Math.ceil(width / CELL_SIZE),
+            Math.ceil(height / CELL_SIZE)
+        );
+
+        pathfinder = new PF.AStarFinder({
+            diagonalMovement: PF.DiagonalMovement.Always,
+            heuristic: PF.Heuristic.euclidean
+        });
+
+        markObstacles(model.value);
+        model.value.scale.set(1, 1, 1);
+        scene.add(toRaw(model.value));
+        addGridHelper();
+        // 启用调试网格可视化(测试时可取消注释)
+        addDebugGrid();
+        
+        loading.value = false;
+    } catch (error) {
+        console.error('模型加载失败:', error);
+        loading.value = false;
+    }
+};
+
+const worldToGrid = (worldPos: THREE.Vector3): [number, number] => {
+    modelBBox.setFromObject(toRaw(model.value));
+    const relativeX = worldPos.x - modelBBox.min.x;
+    const relativeY = worldPos.y - modelBBox.min.y;
+
+    const eps = 0.001;
+    const x = Math.floor((relativeX + eps) / CELL_SIZE);
+    const y = Math.floor((relativeY + eps) / CELL_SIZE);
+
+    return [
+        THREE.MathUtils.clamp(x, 0, grid.width - 1),
+        THREE.MathUtils.clamp(y, 0, grid.height - 1)
+    ];
+};
+// 精确坐标转换方法
+const gridToWorld = (gridX: number, gridY: number): THREE.Vector3 => {
+    // 计算局部坐标
+    const localPos = new THREE.Vector3(
+        modelBBox.min.x + gridX * CELL_SIZE + CELL_SIZE / 2,
+        modelBBox.min.y + gridY * CELL_SIZE + CELL_SIZE / 2,
+        modelBBox.min.z
+    );
+    
+    // 应用模型的世界变换
+    const worldPos = localPos.applyMatrix4(model.value.matrixWorld);
+    worldPos.z += 0.01; // 防止Z-fighting
+    return worldPos;
+};
+
+
+const markObstacles = (obj: THREE.Object3D) => {
+    obj.updateWorldMatrix(true, true);
+    if (obj.userData.isGround) {
+        const [minX, minY] = worldToGrid(new THREE.Vector3(modelBBox.min.x, modelBBox.min.y, 0));
+        const [maxX, maxY] = worldToGrid(new THREE.Vector3(modelBBox.max.x, modelBBox.max.y, 0));
+
+        for (let x = minX; x <= maxX; x++) {
+            for (let y = minY; y <= maxY; y++) {
+                if (x >= 0 && y >= 0 && x < grid.width && y < grid.height) {
+                    grid.setWalkableAt(x, y, true);
+                }
+            }
+        }
+        return;
+    }
+
+    if ((obj.name.toLowerCase().includes('obstacle')) && obj.isMesh) {
+        const box = new THREE.Box3().setFromObject(obj);
+        const worldMin = new THREE.Vector3().copy(box.min).applyMatrix4(obj.matrixWorld);
+        const worldMax = new THREE.Vector3().copy(box.max).applyMatrix4(obj.matrixWorld);
+
+        const [minX, minY] = worldToGrid(worldMin);
+        const [maxX, maxY] = worldToGrid(worldMax);
+
+        for (let x = minX - 1; x <= maxX + 1; x++) {
+            for (let y = minY - 1; y <= maxY + 1; y++) {
+                if (x >= 0 && y >= 0 && x < grid.width && y < grid.height) {
+                    grid.setWalkableAt(x, y, false);
+                }
+            }
+        }
+    }
+
+    obj.children.forEach(child => markObstacles(child));
+};
+
+// 修改调试网格生成方法(完整解决方案)
+const addDebugGrid = () => {
+    // 确保模型变换已更新
+    model.value.updateWorldMatrix(true, true);
+    modelBBox.setFromObject(model.value);
+    
+    // 获取地面实际尺寸
+    const groundWidth = modelBBox.max.x - modelBBox.min.x;
+    const groundHeight = modelBBox.max.y - modelBBox.min.y;
+    const groundZ = modelBBox.min.z + 0.01; // 地面高度偏移
+
+    // 创建物理对齐平面
+    const planeGeometry = new THREE.PlaneGeometry(groundWidth, groundHeight);
+    const gridTexture = createDynamicGridTexture(groundWidth, groundHeight);
+    
+    const planeMaterial = new THREE.MeshBasicMaterial({
+        map: gridTexture,
+        transparent: true,
+        opacity: 0.6,
+        depthWrite: false
+    });
+
+    const debugPlane = new THREE.Mesh(planeGeometry, planeMaterial);
+    
+    // 应用模型的世界变换
+    const position = new THREE.Vector3();
+    model.value.getWorldPosition(position);
+    debugPlane.position.copy(position)
+        .add(new THREE.Vector3(
+            groundWidth / 2 + modelBBox.min.x,
+            groundHeight / 2 + modelBBox.min.y,
+            groundZ - position.z
+        ));
+    
+    // 对齐模型旋转
+    const rotation = new THREE.Euler();
+    model.value.getWorldRotation(rotation);
+    debugPlane.rotation.copy(rotation);
+    debugPlane.rotateX(-Math.PI / 2); // 补偿平面默认垂直方向
+
+    scene.add(debugPlane);
+};
+
+// 创建动态匹配纹理
+const createDynamicGridTexture = (width: number, height: number) => {
+    const canvas = document.createElement('canvas');
+    const ctx = canvas.getContext('2d');
+    
+    // 根据实际尺寸计算单元格数量
+    const cellCountX = Math.round(width / CELL_SIZE);
+    const cellCountY = Math.round(height / CELL_SIZE);
+    
+    // 设置画布分辨率
+    canvas.width = cellCountX * 20; // 每个单元格20像素
+    canvas.height = cellCountY * 20;
+
+    ctx.fillStyle = 'rgba(0,0,0,0)';
+    ctx.fillRect(0, 0, canvas.width, canvas.height);
+    
+    // 绘制精确网格
+    ctx.strokeStyle = '#ff0000';
+    ctx.lineWidth = 2;
+    
+    // 横向线
+    for(let y = 0; y <= cellCountY; y++){
+        const py = y * (canvas.height / cellCountY);
+        ctx.beginPath();
+        ctx.moveTo(0, py);
+        ctx.lineTo(canvas.width, py);
+        ctx.stroke();
+    }
+    
+    // 纵向线
+    for(let x = 0; x <= cellCountX; x++){
+        const px = x * (canvas.width / cellCountX);
+        ctx.beginPath();
+        ctx.moveTo(px, 0);
+        ctx.lineTo(px, canvas.height);
+        ctx.stroke();
+    }
+
+    return new THREE.CanvasTexture(canvas);
+};
+
+const addGridHelper = () => {
+    const gridHelper = new THREE.GridHelper(100, 20);
+    gridHelper.position.y = -0.1;
+    scene.add(gridHelper);
+};
+
+const set2DView = () => {
+    const bbox = new THREE.Box3().setFromObject(toRaw(model.value));
+    const center = bbox.getCenter(new THREE.Vector3());
+    (camera.value as THREE.PerspectiveCamera).position.set(center.x, center.y + 100, center.z);
+    camera.value.lookAt(center);
+    controls.value.enableZoom = true;
+};
+
+const set3DView = () => {
+    (camera.value as THREE.PerspectiveCamera).position.set(50, 50, 50);
+    camera.value.lookAt(0, 0, 0);
+    controls.value.update();
+};
+
+const switchCameraMode = () => {
+    is2DView.value = !is2DView.value;
+    is2DView.value ? set2DView() : set3DView();
+    controls.value.enableRotate = false;
+};
+
+const getMousePosition = (event: MouseEvent): THREE.Vector2 => {
+    const rect = renderer.domElement.getBoundingClientRect();
+    return new THREE.Vector2(
+        ((event.clientX - rect.left) / rect.width) * 2 - 1,
+        -((event.clientY - rect.top) / rect.height) * 2 + 1
+    );
+};
+
+const handleClick = (event: MouseEvent) => {
+    const raycaster = new THREE.Raycaster();
+    const mouse = getMousePosition(event);
+    raycaster.setFromCamera(mouse, toRaw(camera.value));
+
+    const intersects = raycaster.intersectObjects(scene.children, true)
+        .filter(i => i.object.userData.isGround || i.object.parent?.userData?.isGround);
+
+    if (intersects.length > 0) {
+        const point = intersects[0].point.clone();
+        point.z = 0.5;
+        
+        const [gridX, gridY] = worldToGrid(point);
+        if (grid.isWalkableAt(gridX, gridY)) {
+            handlePathPoint(point);
+        } else {
+            console.warn('不可达位置:', gridX, gridY);
+        }
+    }
+};
+
+const handlePathPoint = (point: THREE.Vector3) => {
+    if (!startPoint.value) {
+        startPoint.value = point.clone();
+        addMarker(point, 0x00FF00);
+    } else {
+        endPoint.value = point.clone();
+        addMarker(point, 0xFF0000);
+        calculatePath();
+    }
+};
+
+const calculatePath = () => {
+    if (!startPoint.value || !endPoint.value) return;
+
+    const [startX, startY] = worldToGrid(startPoint.value);
+    const [endX, endY] = worldToGrid(endPoint.value);
+
+    if (!grid.isWalkableAt(startX, startY)) {
+        alert("起点位于障碍物内!");
+        return;
+    }
+
+    const rawPath = pathfinder.findPath(startX, startY, endX, endY, grid.clone());
+
+    if (rawPath.length === 0) {
+        alert("无法找到可行路径");
+        return;
+    }
+
+    const pathPoints = rawPath.map(([x, y]) => gridToWorld(x, y));
+    pathDistance.value = calculatePathLength(pathPoints);
+
+    if (pathLine.value) scene.remove(pathLine.value);
+    const geometry = new THREE.BufferGeometry().setFromPoints(pathPoints);
+    pathLine.value = new THREE.Line(
+        geometry,
+        new THREE.LineBasicMaterial({ color: 0xFF0000, linewidth: 3 })
+    );
+    scene.add(toRaw(pathLine.value));
+};
+
+const calculatePathLength = (points: THREE.Vector3[]): number => {
+    return points.reduce((sum, point, index) => {
+        if (index > 0) {
+            sum += point.distanceTo(points[index - 1]);
+        }
+        return sum;
+    }, 0);
+};
+
+const addMarker = (pos: THREE.Vector3, color: number) => {
+    const geometry = new THREE.ConeGeometry(0.5, 2, 32);
+    const material = new THREE.MeshPhongMaterial({ color });
+    const marker = new THREE.Mesh(geometry, material);
+    marker.position.copy(pos);
+    scene.add(marker);
+    markers.push(marker);
+};
+
+const clearAll = () => {
+    startPoint.value = null;
+    endPoint.value = null;
+    markers.forEach(m => {
+        scene.remove(m);
+        m.geometry.dispose();
+    });
+    markers.length = 0;
+    if (pathLine.value) {
+        scene.remove(toRaw(pathLine.value));
+        pathLine.value.geometry.dispose();
+        pathLine.value = null;
+    }
+    pathDistance.value = 0;
+};
+
+const onWindowResize = () => {
+    const aspect = window.innerWidth / window.innerHeight;
+    if (camera.value instanceof THREE.PerspectiveCamera) {
+        camera.value.aspect = aspect;
+        camera.value.updateProjectionMatrix();
+    }
+    renderer.setSize(window.innerWidth, window.innerHeight);
+};
+
+const initControls = () => {
+    controls.value = new OrbitControls(toRaw(camera.value), renderer.domElement);
+    controls.value.enableDamping = true;
+    controls.value.dampingFactor = 0.05;
+    controls.value.enableRotate = false;
+};
+
+onMounted(async () => {
+    if (!canvasContainer.value) return;
+    initScene();
+    canvasContainer.value.appendChild(renderer.domElement);
+    await loadAirportModel();
+    initControls();
+
+    const bbox = new THREE.Box3().setFromObject(toRaw(model.value));
+    controls.value.target.copy(bbox.getCenter(new THREE.Vector3()));
+
+    renderer.domElement.addEventListener('click', handleClick);
+    window.addEventListener('resize', onWindowResize);
+
+    const animate = () => {
+        requestAnimationFrame(animate);
+        controls.value.update();
+        renderer.render(scene, toRaw(camera.value));
+    };
+    animate();
+});
+
+onBeforeUnmount(() => {
+    renderer.domElement.removeEventListener('click', handleClick);
+    window.removeEventListener('resize', onWindowResize);
+    renderer.dispose();
+});
+</script>
+
+<style scoped>
+/* 保持原有样式不变 */
+.container {
+    position: relative;
+    height: 100vh;
+    background: #f8f9fa;
+}
+
+.canvas-container {
+    width: 100%;
+    height: 100%;
+}
+
+.controls {
+    position: absolute;
+    top: 20px;
+    left: 20px;
+    background: rgba(255, 255, 255, 0.95);
+    padding: 12px 20px;
+    border-radius: 10px;
+    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+    font-family: system-ui, -apple-system, sans-serif;
+    min-width: 280px;
+}
+
+button {
+    padding: 8px 16px;
+    background: #2196F3;
+    border: none;
+    color: white;
+    border-radius: 6px;
+    cursor: pointer;
+    transition: all 0.2s;
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    margin-right: 8px;
+}
+
+button:hover {
+    background: #1976D2;
+    transform: translateY(-1px);
+}
+
+.status {
+    margin-top: 12px;
+    color: #2c3e50;
+    font-size: 14px;
+    line-height: 1.5;
+}
+
+.loading {
+    color: #FF9800;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-weight: 500;
+}
+
+@keyframes spin {
+    to {
+        transform: rotate(360deg);
+    }
+}
+
+.loading::before {
+    content: '';
+    width: 16px;
+    height: 16px;
+    border: 2px solid currentColor;
+    border-top-color: transparent;
+    border-radius: 50%;
+    animation: spin 0.8s linear infinite;
+}
+</style>

+ 151 - 0
src/views/home/path.vue

@@ -0,0 +1,151 @@
+<template>
+    <div ref="container" style="width: 100vw; height: 100vh"></div>
+  </template>
+  
+  <script setup>
+  import { ref, onMounted, onUnmounted } from 'vue'
+  import * as THREE from 'three'
+  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
+  import { Pathfinding } from 'three-pathfinding'
+  
+  const container = ref(null)
+  let scene, camera, renderer, model
+  let raycaster = new THREE.Raycaster()
+  let mouse = new THREE.Vector2()
+  let navMesh, pathfinding, zoneID = 'level1'
+  let startPoint = null, endPoint = null, pathLine = null
+  
+  // 初始化场景
+  function initScene() {
+    scene = new THREE.Scene()
+    camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
+    renderer = new THREE.WebGLRenderer({ antialias: true })
+    renderer.setSize(window.innerWidth, window.innerHeight)
+    container.value.appendChild(renderer.domElement)
+    camera.position.set(5, 5, 5)
+    camera.lookAt(0, 0, 0)
+  }
+  
+  // 自动生成导航网格
+  function createNavMesh(model) {
+    const box = new THREE.Box3().setFromObject(model)
+    const size = box.getSize(new THREE.Vector3())
+    
+    // 根据模型尺寸计算网格密度
+    const density = 0.5 // 每单位长度细分数量(数值越大越精确)
+    const segments = {
+      width: Math.ceil(size.x * density),
+      height: Math.ceil(size.y * density)
+    }
+  
+    // 创建平面导航网格
+    const geometry = new THREE.PlaneGeometry(size.x, size.y, segments.width, segments.height)
+    geometry.rotateX(-Math.PI / 2)
+    geometry.translate(
+      box.getCenter().x,
+      0, // Z轴设为0
+      box.getCenter().y
+    )
+    
+    // 生成Pathfinding需要的导航数据
+    return Pathfinding.createZone(geometry)
+  }
+  
+  // 加载模型并初始化导航
+  async function loadModel() {
+    const loader = new GLTFLoader()
+    const gltf = await loader.loadAsync('/models/scene.glb')
+    
+    model = gltf.scene
+    model.traverse(child => {
+      if (child.isMesh) {
+        child.position.z = 0
+        child.updateMatrix()
+      }
+    })
+    scene.add(model)
+  
+    // 自动生成导航网格
+    pathfinding = new Pathfinding()
+    const zone = createNavMesh(model)
+    pathfinding.setZoneData(zoneID, zone)
+  }
+  
+  // 获取点击位置(强制Z=0)
+  function getFloorPosition(intersect) {
+    return new THREE.Vector3(intersect.point.x, intersect.point.y, 0)
+  }
+  
+  // 点击事件处理
+  function onMouseClick(event) {
+    mouse.set(
+      (event.clientX / window.innerWidth) * 2 - 1,
+      -(event.clientY / window.innerHeight) * 2 + 1
+    )
+    
+    raycaster.setFromCamera(mouse, camera)
+    const intersects = raycaster.intersectObjects(scene.children, true)
+    
+    if (intersects.length > 0) {
+      const point = getFloorPosition(intersects[0])
+      
+      if (!startPoint) {
+        startPoint = point.clone()
+        addMarker(point, 0x00ff00) // 绿色起点标记
+      } else {
+        endPoint = point.clone()
+        addMarker(point, 0xff0000) // 红色终点标记
+        calculatePath()
+      }
+    }
+  }
+  
+  // 路径计算
+  function calculatePath() {
+    const groupID = pathfinding.getGroup(zoneID, startPoint)
+    const path = pathfinding.findPath(startPoint, endPoint, zoneID, groupID)
+  
+    if (path && path.length > 1) {
+      if (pathLine) scene.remove(pathLine)
+      
+      const points = path.map(p => new THREE.Vector3(p.x, p.y, 0))
+      const geometry = new THREE.BufferGeometry().setFromPoints(points)
+      const material = new THREE.LineBasicMaterial({ color: 0x0000ff, linewidth: 2 })
+      pathLine = new THREE.Line(geometry, material)
+      scene.add(pathLine)
+    }
+  
+    startPoint = endPoint = null
+  }
+  
+  // 添加标记点
+  function addMarker(position, color) {
+    const geometry = new THREE.SphereGeometry(0.1)
+    const material = new THREE.MeshBasicMaterial({ color })
+    const marker = new THREE.Mesh(geometry, material)
+    marker.position.copy(position)
+    scene.add(marker)
+  }
+  
+  // 动画循环
+  function animate() {
+    requestAnimationFrame(animate)
+    renderer.render(scene, camera)
+  }
+  
+  onMounted(() => {
+    initScene()
+    loadModel()
+    animate()
+    window.addEventListener('click', onMouseClick)
+    window.addEventListener('resize', () => {
+      camera.aspect = window.innerWidth / window.innerHeight
+      camera.updateProjectionMatrix()
+      renderer.setSize(window.innerWidth, window.innerHeight)
+    })
+  })
+  
+  onUnmounted(() => {
+    window.removeEventListener('click', onMouseClick)
+  })
+  </script>

+ 263 - 0
src/views/threeMap/components/Character/Character.ts

@@ -0,0 +1,263 @@
+import {FBXLoader} from "three/examples/jsm/loaders/FBXLoader.js";
+import {
+	AnimationMixer,
+	BufferGeometry,
+	CanvasTexture,
+	Group,
+	Line,
+	LineBasicMaterial, Mesh,
+	Object3D,
+	PerspectiveCamera,
+	Scene,
+	Sprite,
+	SpriteMaterial,
+	TextureLoader,
+	Vector3
+} from "three";
+import {EventEmitter} from "@/utils/EventEmitter";
+import type {ColorRepresentation} from "three/src/utils";
+import {createLine} from "@/hooks/useMesh";
+import {track_line_src} from "./Constants";
+
+const texture_loader = new TextureLoader();
+
+const track_line_texture = texture_loader.load(track_line_src);
+// track_line_texture.wrapS = RepeatWrapping;
+// track_line_texture.wrapT = RepeatWrapping;
+// track_line_texture.repeat.set(3, 1);
+
+export class Character extends EventEmitter {
+	private MOVE_SPEED = 4;
+	private path_line_color: ColorRepresentation = 0xff9900;
+	private PATH_LINE_OFFSET_Y = 1.2;
+
+	private camera: PerspectiveCamera;
+	private scene: Scene;
+	private fbx_loader = new FBXLoader();
+
+	private character_model: Group | undefined;
+	private mixer: AnimationMixer | undefined;
+	private action_walk: any;
+	private quaternion_helper: Object3D | undefined;
+
+	private paths: Vector3[] = [];
+
+	// 判断是否处于多场景导航
+	public is_cross_floor_navigation = false;
+
+	private sprite_tips!: Sprite;
+
+	private line: Mesh | undefined;
+
+	constructor(camera: PerspectiveCamera, scene: Scene, url: string, position: Vector3) {
+		super();
+
+		this.camera = camera;
+
+		this.scene = scene;
+
+		// this.fbx_loader.load(url, (object) => {
+		// 	this.character_model = object;
+		// 	this.character_model.name = "character";
+
+		// 	this.character_model.position.copy(position);
+
+		// 	this.mixer = new AnimationMixer(object);
+
+		// 	this.action_walk = this.mixer.clipAction(object.animations[1]);
+
+		// 	this.quaternion_helper = new Object3D();
+
+		// 	this.createSpriteTips();
+		// });
+	}
+
+	addToScene(pos: Vector3 = new Vector3(0, 0, 0)) {
+		if (this.character_model && this.scene.getObjectByName("character")) {
+			this.character_model.position.copy(pos);
+			return;
+		}
+
+		if (this.character_model) {
+			this.character_model.position.copy(pos);
+			this.scene.add(this.character_model);
+		}
+	}
+
+	removeToScene() {
+		if (this.character_model && this.scene.getObjectByName("character")) {
+			this.hideTips();
+
+			this.scene.remove(this.character_model);
+
+			this.paths = [];
+		}
+	}
+
+    setPath(paths: Vector3[]) {
+		if (this.paths.length) return;
+		this.playWalk();
+		this.paths = paths;
+		this.drawPathLine(this.paths);
+	}
+
+	update(delta: number) {
+		if (this.paths && this.paths.length) {
+			this.animateModel(delta);
+		} else if (this.paths && this.paths.length === 0) {
+			this.stopWalk();
+		}
+
+		this.updateSprite();
+		this.updateLine();
+	}
+
+	getPosition() {
+		return this.character_model?.position as Vector3;
+	}
+
+	setPosition(position: Vector3) {
+		this.character_model && this.character_model.position.copy(position);
+	}
+
+	setPathLineColor(color: ColorRepresentation) {
+		this.path_line_color = color;
+	}
+
+	private drawPathLine(paths: Vector3[]) {
+		this.clearPath();
+		// const line_arr: number[] = [];
+
+		// paths.forEach(path => {
+		// 	line_arr.push(path.x, path.y, path.z);
+		// });
+
+		const line = createLine(paths, 0.1, 0xff9900, 2, track_line_texture);
+		line.name = "line";
+		this.line = line;
+		this.scene.add(line);
+	}
+
+	clearPath() {
+		const beforeLine = (this.scene.getObjectByName("line") as Line<BufferGeometry, LineBasicMaterial>);
+
+		if (beforeLine) {
+			beforeLine.material.dispose();
+			beforeLine.geometry.dispose();
+			this.scene.remove(beforeLine);
+        }
+        this.paths =[]
+	}
+
+    private animateModel(delta: number) {
+		this.mixer?.update(delta);
+        
+		if (this.paths && this.character_model && this.quaternion_helper) {
+			const targetPosition = this.paths[0];
+			const velocity = targetPosition.clone().sub(this.character_model.position);
+
+			this.quaternion_helper.position.copy(this.character_model.position);
+			this.quaternion_helper.rotation.copy(this.character_model.rotation);
+			this.quaternion_helper.lookAt(targetPosition);
+			this.character_model.quaternion.slerp(this.quaternion_helper.quaternion, 0.1);
+
+			if (velocity.lengthSq() > 0.05 * 0.05) {
+				velocity.normalize();
+				this.character_model.position.add(velocity.multiplyScalar(delta * this.MOVE_SPEED));
+
+				this.setCameraLookAt(this.quaternion_helper);
+			} else {
+				this.paths.shift();
+				if (!this.paths.length) {
+					this.dispatchEvent("on-path-end");
+				}
+			}
+		}
+	}
+
+	private setCameraLookAt(quaternion_helper: Object3D) {
+		const player_pos = quaternion_helper.position.clone(); // 获取角色位置
+		const camera_offset = new Vector3(0, 2, 0); // 设置相机的偏移量
+		const camera_direction = new Vector3(0, 1, -4); // 设置相机的方向向量
+		camera_direction.applyQuaternion(quaternion_helper.quaternion); // 旋转相机方向向量以跟随角色方向
+		const camera_pos = player_pos.clone().add(camera_direction); // 计算相机位置
+		camera_pos.add(camera_offset); // 添加相机的偏移量
+		this.camera.position.lerp(camera_pos, 0.1); // 平滑设置相机位置
+		this.camera.lookAt(player_pos); // 将相机指向角色位置
+	}
+
+	// 手动改变character位置时需调用此函数设置相机跟随
+	adjustCameraLookAt(target_position: Vector3) {
+		if (this.quaternion_helper && this.character_model) {
+			this.quaternion_helper.position.copy(this.character_model.position);
+			this.quaternion_helper.rotation.copy(this.character_model.rotation);
+			this.quaternion_helper.lookAt(target_position);
+			this.character_model.quaternion.slerp(this.quaternion_helper.quaternion, 0.1);
+
+			this.setCameraLookAt(this.quaternion_helper);
+		}
+	}
+
+	private playWalk() {
+		this.action_walk?.play();
+	}
+
+	private stopWalk() {
+		this.action_walk?.stop();
+	}
+
+	private createSpriteTips() {
+		const canvas = document.createElement("canvas");
+		canvas.width = 1024;
+		canvas.height = 512;
+
+		const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+		context.fillStyle = "rgba(100, 100, 100, 1)";
+		context.fillRect(0, 256, 2048, 256);
+		context.textAlign = "center";
+		context.textBaseline = "middle";
+		context.font = "bold 150px Arial";
+		context.fillStyle = "rgba(255, 255, 255, 1)";
+		context.fillText("切换楼层中", canvas.width / 2, canvas.height / 1.3);
+
+		const texture = new CanvasTexture(canvas);
+
+		const material = new SpriteMaterial({
+			map: texture,
+			color: 0xffffff,
+			transparent: true,
+			opacity: 0.9
+		});
+
+		const sprite = new Sprite(material);
+		sprite.position.copy(this.character_model!.position);
+		sprite.visible = false;
+
+		this.sprite_tips = sprite;
+
+		this.scene.add(sprite);
+	}
+
+	showTips() {
+		this.sprite_tips.visible = true;
+	}
+
+	hideTips() {
+		this.sprite_tips.visible = false;
+	}
+
+	updateSprite() {
+		if (this.sprite_tips && this.character_model) {
+			this.sprite_tips.position.copy(this.character_model.position);
+			this.sprite_tips.translateY(2.5);
+		}
+	}
+
+	updateLine() {
+		if (this.line) {
+			// @ts-ignore
+			// this.line.material.uniforms.uOffset.value.x -= 0.01;
+			this.line.material.map.offset.x -= 0.01;
+		}
+	}
+}

+ 3 - 0
src/views/threeMap/components/Character/Constants.ts

@@ -0,0 +1,3 @@
+export const character_src = new URL("/path/person.fbx", import.meta.url).href;
+
+export const track_line_src = new URL("/path/pipe.png", import.meta.url).href;

+ 226 - 0
src/views/threeMap/components/PathFinder/Graph.ts

@@ -0,0 +1,226 @@
+import type {Vector3} from "three";
+
+type Vertex = {
+	key: string,
+	pos: Vector3
+}
+
+// 节点列表
+type Vertices = Map<string, Vector3>
+
+// 邻接表
+type AdjList = Map<string, string[]>
+
+// 访问状态
+const Status = {
+	unknown: 0,
+	visited: 1,
+	finished: 2,
+};
+
+// 无权无向图
+export class Graph {
+	private readonly is_directed: boolean;
+	private readonly vertices: Vertices;
+	private readonly adj_list: AdjList;
+
+	constructor(is_directed = false) {
+		this.is_directed = is_directed;
+		this.vertices = new Map();
+		this.adj_list = new Map();
+	}
+
+	addVertex(v: Vertex) {
+		if (!this.vertices.has(v.key)) {
+			this.vertices.set(v.key, v.pos);
+			this.adj_list.set(v.key, []);
+		}
+	}
+
+	addEdge(v: string, w: string) {
+		this.adj_list.get(v)?.push(w);
+
+		if (!this.is_directed) {
+			this.adj_list.get(w)?.push(v);
+		}
+	}
+
+	getVertices() {
+		return this.vertices;
+	}
+
+	getAdjList() {
+		return this.adj_list;
+	}
+
+	// 获得所有节点相邻节点映射
+	getConnectMap() {
+		let map: Record<string, string> = {};
+
+		for (const key of this.vertices.keys()) {
+			map[key] = "";
+			const neighbors = this.adj_list.get(key) as string[];
+			for (const element of neighbors) {
+				map[key] += `${element}、`;
+			}
+			map[key] = map[key].slice(0, map[key].length - 1);
+		}
+
+		return map;
+	}
+
+	// 广度优先遍历查询
+	breadthFirstSearch(start_vertex: string, callback: (vertex: string) => any): void {
+		const vertices = this.getVertices();
+		const adj_list = this.getAdjList();
+
+		// 将所有顶点状态重置
+		const status = this.initializeStatus(vertices);
+
+		const queue: string[] = [];
+		queue.push(start_vertex);
+
+		while (queue.length) {
+			const u = queue.shift() as string;
+
+			const neighbors = adj_list.get(u) as string[];
+
+			status[u] = Status.visited;
+
+			for (const element of neighbors) {
+				const w = element;
+				if (status[w] === Status.unknown) {
+					status[w] = Status.visited;
+					queue.push(w);
+				}
+			}
+
+			status[u] = Status.finished;
+
+			if (callback) {
+				callback(u);
+			}
+		}
+	}
+
+	private initializeStatus(vertices: Vertices) {
+		const vertices_status: {[key: string]: number} = {};
+
+		for (const key of vertices.keys()) {
+			vertices_status[key] = Status.unknown;
+		}
+
+		return vertices_status;
+	}
+
+	BFS(start: string, end: string) {
+		// 创建一个队列用于存储待处理的节点
+		const queue = [start];
+		// 创建一个数组用于存储已访问过的节点
+		const visited = new Set<string>();
+		// 创建一个Map对象用于存储节点到其前一个节点的映射
+		const predecessors = new Map<string, string>();
+
+		// 当队列不为空时,循环处理队列中的节点
+		while (queue.length > 0) {
+			// 从队列中取出一个节点进行处理
+			const current = queue.shift() as string;
+			visited.add(current);
+
+			// 如果当前节点是终点,说明已经找到了最短路径,可以退出循环
+			if (current === end) {
+				break;
+			}
+
+			// 遍历当前节点的所有邻居节点
+			for (const neighbor of this.getAdjList().get(current) as string[]) {
+				// 如果邻居节点还没有被访问过,则将其加入队列中
+				if (!visited.has(neighbor)) {
+					queue.push(neighbor);
+					visited.add(neighbor);
+					predecessors.set(neighbor, current);
+				}
+			}
+		}
+
+		// 如果没有找到终点,说明没有可达路径,返回空数组
+		if (!predecessors.has(end)) {
+			return [];
+		}
+
+		// 从终点开始回溯到起点,构建最短路径数组
+		const path = [end];
+		let current = end;
+		while (current !== start) {
+			const predecessor = predecessors.get(current) as string;
+			path.unshift(predecessor);
+			current = predecessor;
+		}
+
+		return path;
+	}
+
+	BFSAll(start: string, end: string) {
+		// 创建一个队列用于存储待处理的节点
+		const queue = [start];
+		// 创建一个Map对象用于存储节点到起点的距离
+		const distances = new Map<string, number>();
+		// 创建一个Map对象用于存储节点到其前驱节点列表的映射
+		const predecessors = new Map<string, string[]>();
+		// 初始化起点的距离为0,并将其加入队列和已访问列表中
+		distances.set(start, 0);
+		predecessors.set(start, []);
+		const visited = new Set([start]);
+
+		// 当队列不为空时,循环处理队列中的节点
+		while (queue.length > 0) {
+			// 从队列中取出一个节点进行处理
+			const current = queue.shift() as string;
+
+			// 遍历当前节点的所有邻居节点
+			for (const neighbor of this.getAdjList().get(current) as string[]) {
+				// 如果邻居节点还没有被访问过,则将其加入队列中,并更新其距离和前驱节点列表
+				if (!visited.has(neighbor)) {
+					queue.push(neighbor);
+					visited.add(neighbor);
+					// 更新邻居节点的距离和前驱节点列表
+					const distance = distances.get(current) as number + 1;
+					distances.set(neighbor, distance);
+					predecessors.set(neighbor, [current]);
+				} else {
+					// 如果邻居节点已经被访问过,比较新的距离和原有距离的大小
+					const distance = distances.get(current) as number + 1;
+					if (distance === distances.get(neighbor)) { // 如果相等,将当前节点添加到其前驱节点列表中
+						(predecessors.get(neighbor) as string[]).push(current);
+					} else if (distance < (distances.get(neighbor) as number)) { // 如果新的距离更小,则更新距离和前驱节点列表
+						distances.set(neighbor, distance);
+						predecessors.set(neighbor, [current]);
+					}
+				}
+			}
+		}
+
+		// 如果终点不可达,返回空数组
+		if (!predecessors.has(end)) {
+			return [];
+		}
+
+		// 从终点开始回溯到起点,构建所有的最短路径
+		const paths: string[][] = [];
+		backtrack(end, start, []);
+		return paths;
+
+		// 回溯路径的函数
+		function backtrack(current: string, target: string, path: string[]) {
+			path.unshift(current);
+			if (current === target) {
+				paths.push(path.slice());
+			} else {
+				for (const predecessor of (predecessors.get(current) as string[])) {
+					backtrack(predecessor, target, path);
+				}
+			}
+			path.shift();
+		}
+	}
+}

+ 45 - 0
src/views/threeMap/components/PathFinder/PathFinder.ts

@@ -0,0 +1,45 @@
+// @ts-ignore
+import {Pathfinding} from "three-pathfinding";
+import type {Mesh, Vector3} from "three";
+
+export class PathFinder {
+	private path_finder: Pathfinding = new Pathfinding();
+	private nav_mesh_file: Record<string, Mesh> = {};
+
+	// 设置pathfinding的navmesh数据
+	setZoneData(floor_key: string, mesh: Mesh) {
+		if (this.nav_mesh_file[floor_key]) return;
+        this.nav_mesh_file[floor_key] = mesh;
+        console.log(mesh.geometry);
+        
+		this.path_finder.setZoneData(floor_key, Pathfinding.createZone(mesh.geometry));
+	}
+
+	// 查询单地图路径
+	queryPath(zone: string, start: Vector3, end: Vector3) {
+		const path = this.findPath(zone, start, end);
+		if (path) { // 如果出发坐标到终点坐标都有可达路线(start - end)
+			return path;
+		} else { // 终点如果不可达,找终点附近最近的点(start - near_end)
+			const near_end = this.path_finder.getClosestNode(end, zone, this.path_finder.getGroup(zone, start)).centroid;
+			const start_to_near_end_path = this.findPath(zone, start, near_end);
+
+			if (start_to_near_end_path) {
+				return start_to_near_end_path;
+			} else { // (卡死情况)如果出发坐标都不可取,则找出发位置最近的点,再到终点(near_start - end)
+				const near_start = this.path_finder.getClosestNode(start, zone, this.path_finder.getGroup(zone, start)).centroid;
+				const near_start_to_end_path = this.findPath(zone, near_start, end);
+
+				if (near_start_to_end_path) {
+					return near_start_to_end_path;
+				} else { // (卡死情况)如果出发坐标和终点坐标都不可取,则从找出两个坐标最近的点,再到终点(near_start - near_end)
+					return this.findPath(zone, near_start, near_end);
+				}
+			}
+		}
+	}
+
+	private findPath(zone: string, start: Vector3, end: Vector3) {
+		return this.path_finder.findPath(start, end, zone, this.path_finder.getGroup(zone, start));
+	}
+}

+ 199 - 0
src/views/threeMap/components/PathFinder/WeightedGraph.ts

@@ -0,0 +1,199 @@
+import type {Vector3} from "three";
+
+type AdjacentList = Map<string, Map<string, number>>;
+
+type VertexPosition = {x: number, y: number, z: number} | Vector3;
+
+type Vertices = Map<string, VertexPosition>;
+
+export class WeightedGraph {
+	private readonly is_directed: boolean;
+	private adj_list: AdjacentList;
+	private readonly vertices: Vertices;
+
+	constructor(is_directed = true) {
+		this.is_directed = is_directed;
+
+		this.adj_list = new Map();
+
+		this.vertices = new Map();
+	}
+
+	addVertex(vertex: {key: string, pos: VertexPosition}): void {
+		if (!this.adj_list.has(vertex.key)) {
+			this.adj_list.set(vertex.key, new Map<string, number>());
+			this.vertices.set(vertex.key, vertex.pos);
+		}
+	}
+
+	addEdge(vertex1: string, vertex2: string, weight: number): void {
+		this.adj_list.get(vertex1)!.set(vertex2, weight);
+		if (!this.is_directed) {
+			this.adj_list.get(vertex2)!.set(vertex1, weight);
+		}
+	}
+
+	toString() {
+		let s = "";
+
+		this.adj_list.forEach((val,  key) => {
+			s += `${key}: \n`;
+			val.forEach((weight, key) => {
+				s += `\t-> ${key}: ${weight} \n`;
+			});
+			s += "\n";
+		});
+
+		return s;
+	}
+
+	getVertices() {
+		return this.vertices;
+	}
+
+	getAdjList() {
+		return this.adj_list;
+	}
+
+	dijkstra(start_node: string): Map<string, number> {
+		const distances = new Map<string, number>();
+		const visited = new Set<string>();
+		const queue = new Map<string, number>();
+
+		// 初始化起始节点到其他节点的距离,起始节点为0,其余为Infinity
+		for (const vertex of this.adj_list.keys()) {
+			distances.set(vertex, Infinity);
+		}
+		distances.set(start_node, 0);
+
+		// 将起始节点加入队列
+		queue.set(start_node, 0);
+
+		while (queue.size > 0) {
+			const [current_node, current_distance] = this.getShortestDistanceNode(queue);
+			visited.add(current_node);
+
+			// 更新当前节点相邻节点的最短距离
+			for (const [neighbor_node, weight] of this.adj_list.get(current_node)!) {
+				const distance = current_distance + weight;
+				if (distance < distances.get(neighbor_node)!) {
+					distances.set(neighbor_node, distance);
+				}
+
+				// 如果相邻节点未被访问过,则加入队列
+				if (!visited.has(neighbor_node)) {
+					queue.set(neighbor_node, distances.get(neighbor_node)!);
+				}
+			}
+		}
+
+		return distances;
+	}
+
+	private getShortestDistanceNode(queue: Map<string, number>): [string, number] {
+		const entries = Array.from(queue.entries());
+		const shortest_distance_entry = entries.reduce((min_entry, entry) => {
+			return entry[1] < min_entry[1] ? entry : min_entry;
+		}, entries[0]);
+		queue.delete(shortest_distance_entry[0]);
+		return shortest_distance_entry as [string, number];
+	}
+
+	dfsAllShortestPaths(start_node: string, end_node: string): string[][] {
+		const paths: string[][] = [];
+		const visited = new Set<string>();
+
+		const dfs = (current_node: string, current_path: string[], current_distance: number) => {
+			if (current_node === end_node) {
+				if (current_distance === this.dijkstra(start_node).get(end_node)) {
+					paths.push(current_path);
+				}
+				return;
+			}
+
+			visited.add(current_node);
+			for (const [neighbor_node, weight] of this.adj_list.get(current_node)!) {
+				if (!visited.has(neighbor_node)) {
+					const distance = current_distance + weight;
+					const new_path = [...current_path, neighbor_node];
+					dfs(neighbor_node, new_path, distance);
+				}
+			}
+			visited.delete(current_node);
+		};
+
+		dfs(start_node, [start_node], 0);
+
+		return paths;
+	}
+
+	dijkstraAllShortestPaths(start_node: string, end_node: string): {shortest_paths: string[][], shortest_paths_weight: Record<string, number>} {
+		const visited = new Set<string>();
+
+		const predecessors = new Map<string, string[]>();
+		const distances = new Map<string, number>();
+		// 将所有节点的距离初始化为无穷大,并初始化全部节点的前置节点
+		for (const vertex of this.adj_list.keys()) {
+			predecessors.set(vertex, []);
+			distances.set(vertex, Infinity);
+		}
+		// 设置起始节点距离为0。
+		distances.set(start_node, 0);
+
+		while (visited.size < this.adj_list.size) {
+			let min_distance = Infinity;
+			let min_node: string | undefined = undefined;
+			// 每一轮都从未被访问过的节点里找出最小距离的节点
+			for (const [node, distance] of distances) {
+				if (!visited.has(node) && distance < min_distance) {
+					min_distance = distance;
+					min_node = node;
+				}
+			}
+
+			if (min_node === undefined) {
+				break;
+			} else {
+				// 找到最小距离的节点后标记访问该节点的相邻节点
+				visited.add(min_node);
+				for (const [neighbor_node, weight] of this.adj_list.get(min_node)!) {
+					if (!visited.has(neighbor_node)) {
+						const distance = min_distance + weight;
+						if (distance < distances.get(neighbor_node)!) {
+							distances.set(neighbor_node, distance);
+							predecessors.set(neighbor_node, [min_node]);
+						} else if (distance === distances.get(neighbor_node)!) {
+							predecessors.get(neighbor_node)!.push(min_node);
+						}
+					}
+				}
+			}
+		}
+
+		const shortest_paths: string[][] = [];
+		const dfs = (current_node: string, current_path: string[]) => {
+			if (current_node === start_node) {
+				shortest_paths.push(current_path.reverse());
+				return;
+			}
+			for (const predecessor of predecessors.get(current_node)!) {
+				dfs(predecessor, [...current_path, predecessor]);
+			}
+		};
+
+		for (const predecessor of predecessors.get(end_node)!) {
+			dfs(predecessor, [end_node, predecessor]);
+		}
+
+		const shortest_paths_weight: Record<string, number> = {};
+		shortest_paths.forEach(path => {
+			// @ts-ignore
+			shortest_paths_weight[path.join("_")] = distances.get(end_node);
+		});
+
+		return {
+			shortest_paths_weight,
+			shortest_paths
+		};
+	}
+}

+ 72 - 0
src/views/threeMap/components/PathFinder/initConnectPoint.ts

@@ -0,0 +1,72 @@
+import {Vector3} from "three";
+import {WeightedGraph} from "./WeightedGraph";
+
+export const initConnectPoint = () => {
+	// 地图对应的可出入连通点
+	const map_conn_points: Map<string, Map<string, Vector3>> = new Map([
+		[
+			"floor_9",
+			new Map([
+				[
+					"A",
+					new Vector3(5.5, 0, 0)
+				],
+				[
+					"B",
+					new Vector3(18, 0, 0)
+				],
+				[
+					"C",
+					new Vector3(19, 0, -5)
+				]
+			])
+		],
+		[
+			"floor_10",
+			new Map([
+				[
+					"D",
+					new Vector3(-6, 0, 0)
+				],
+				[
+					"E",
+					new Vector3(4, 0, -10)
+				]
+			])
+		],
+		[
+			"out",
+			new Map([
+				[
+					"F",
+					new Vector3(19.5, 0, -5)
+				],
+				[
+					"G",
+					new Vector3(5.5, 0, 0)
+				]
+			])
+		]
+	]);
+
+	const graph = new WeightedGraph(false);
+
+	map_conn_points.forEach((points) => {
+		points.forEach((point, point_key) => {
+			graph.addVertex({key: point_key, pos: point});
+		});
+	});
+
+	graph.addEdge("A", "D", 2);
+	graph.addEdge("B", "D", 1);
+	graph.addEdge("C", "D", 1);
+	graph.addEdge("C", "E", 2);
+	graph.addEdge("D", "E", 1);
+	graph.addEdge("E", "F", 2);
+	graph.addEdge("E", "G", 1);
+
+	return {
+		map_conn_points,
+		graph
+	};
+};

+ 93 - 0
src/views/threeMap/components/PathFinder/useFloorConnectFinder.ts

@@ -0,0 +1,93 @@
+import type {Vector3} from "three";
+import type {WeightedGraph} from "./WeightedGraph";
+
+export const useFloorConnectFinder = (map_conn_points: Map<string, Map<string, Vector3>>, graph: WeightedGraph) => {
+	// 找到起始地图到终点地图的所有路线,并过滤出最短路径
+	const findNavPath = (start_floor: string, end_floor: string, start_pos: Vector3) => {
+		if (!map_conn_points.has(start_floor) || !map_conn_points.has(end_floor)) return null;
+
+		// const vertices = graph.getVertices();
+		const paths: string[][] = [];
+		let weight: Record<string, number> = {};
+
+		for (const start_point of map_conn_points.get(start_floor)!.keys()) {
+			for (const end_point of map_conn_points.get(end_floor)!.keys()) {
+				const {shortest_paths, shortest_paths_weight} = graph.dijkstraAllShortestPaths(start_point, end_point);
+				if (shortest_paths.length) {
+					paths.push(...shortest_paths);
+					weight = {
+						...weight,
+						...shortest_paths_weight
+					};
+				}
+			}
+		}
+
+		// 找到总权重最小的路径
+		let min_path_weight = Infinity;
+		for (const path in weight) {
+			if (weight[path] < min_path_weight) {
+				min_path_weight = weight[path];
+			}
+		}
+
+		const filter_shortest_paths = paths.filter(path => {
+			return weight[path.join("_")] === min_path_weight;
+		});
+
+		// 找出距离起始坐标位置最近的连通点路径(优化体验)
+		// let point_distance_to_start_pos = Infinity;
+		// let distance_to_start_pos_path_key = "";
+		// filter_shortest_paths.forEach(path => {
+		// 	const distance = start_pos.distanceTo(vertices.get(path[0]) as Vector3);
+		// 	if (distance < point_distance_to_start_pos) {
+		// 		point_distance_to_start_pos = distance;
+		// 		distance_to_start_pos_path_key = path.join("_");
+		// 	}
+		// });
+
+		return filter_shortest_paths.map(path => ({
+			path,
+			weight: min_path_weight,
+			// nearest_to_current: path.join("_") === distance_to_start_pos_path_key
+		}));
+	};
+
+	const getPathPointPosition = (path: string[]) => {
+		const vertices = graph.getVertices();
+
+		return path.map(key => {
+			return {
+				key,
+				pos: vertices.get(key) as Vector3
+			};
+		});
+	};
+
+	// 根据连通点Key找到所属楼层
+	const findFloorByKey = (key: string) => {
+		for (const [floor_key, floor] of map_conn_points.entries()) {
+			if (floor.has(key)) {
+				return floor_key;
+			}
+		}
+	};
+
+	// 全部连通点Key所属楼层映射
+	const getConnectPointKeyMap = () => {
+		const map: Record<string, string> = {};
+
+		for (const point_key of graph.getVertices().keys()) {
+			// @ts-ignore
+			map[point_key] = findFloorByKey(point_key);
+		}
+
+		return map;
+	};
+
+	return {
+		findNavPath,
+		getPathPointPosition,
+		getConnectPointKeyMap
+	};
+};

+ 10 - 0
src/views/threeMap/components/ThreeMap/Constants.ts

@@ -0,0 +1,10 @@
+export const Floor: Record<string, string> = {
+	"floor_9": new URL("/path/floor_9_nav.glb", import.meta.url).href,
+	"floor_10": new URL("/path/floor_10_nav.glb", import.meta.url).href,
+	"out": new URL("/path/out_nav.glb", import.meta.url).href,
+	"air": new URL("/path/air.glb", import.meta.url).href,
+};
+
+export const cycle_light_src = new URL("/path/cycle_light.png", import.meta.url).href;
+
+export const coordinate_src = new URL("/path/coordinate.png", import.meta.url).href;

+ 248 - 0
src/views/threeMap/components/ThreeMap/NavMap.vue

@@ -0,0 +1,248 @@
+<template>
+<div
+	ref="map_container_ref"
+	:class="['three-map', props.className]"
+	:style="{
+		width: props.width,
+		height: props.height
+	}"
+/>
+</template>
+
+<script setup lang="ts">
+import {ref, shallowRef, inject} from "vue";
+import {useInitThree} from "./useInitThree";
+import {Group, RepeatWrapping, Texture, TextureLoader, Vector3} from "three";
+import {useGLTFLoader} from "@/hooks/useGLTFLoader";
+import {cycle_light_src, Floor} from "./Constants";
+import {getRandomIntInclusive, isMesh} from "@/hooks/Utils";
+import {Character} from "../Character/Character";
+import {useFloorConnectFinder} from "../PathFinder/useFloorConnectFinder";
+import {PathFinder} from "../PathFinder/PathFinder";
+import {useConnectPoint} from "@/hooks/useMesh";
+import {character_src} from "../Character/Constants";
+import {connect_point_inject_key} from "../../global";
+
+const props = withDefaults(defineProps<{
+	className: string,
+	width: string,
+	height: string
+}>(), {
+	className: "nav-map"
+});
+
+const {
+	map_conn_points,
+	graph
+} = inject(connect_point_inject_key)!;
+
+const cur_map = shallowRef<Group | null>(null);
+
+const map_file_cache:Record<string, Group> = {};
+
+const map_container_ref = ref<HTMLElement>();
+
+const {
+	scene,
+	camera,
+	renderer,
+	clock,
+	controls,
+	setFullScreenSize,
+	renderCSS2D
+} = useInitThree(map_container_ref);
+
+const character = new Character(camera, scene, character_src, new Vector3(0, 0, 0));
+
+const handlePathEnd = () => {
+	if (character.is_cross_floor_navigation) {
+		const next_point = cross_floor_path.shift();
+		const next_point_floor = next_point ? connect_floor_map[next_point.key] : undefined;
+
+		if (next_point && next_point_floor && next_point_floor === cur_map.value!.userData.floor_key) {
+			// 如果下一个导航点是属于当前地图的,则执行室内导航
+			createPath(next_point_floor, character.getPosition(), next_point.pos);
+		} else if (next_point && next_point_floor && next_point_floor !== cur_map.value!.userData.floor_key) {
+			// 如果下一个导航点不属于当前地图,则在切换楼层后执行此函数
+			character.showTips();
+
+			setTimeout(() => {
+				setfloorId(next_point_floor)
+					.then(() => {
+						const move_to = next_point.pos;
+						character.addToScene(move_to);
+						character.adjustCameraLookAt(move_to);
+						handlePathEnd();
+					});
+			}, 1000);
+		} else if (next_point && next_point.key.endsWith("inside")) {
+			// 下一个点为结束坐标,结束坐标一定跟最后一个连通点在同一楼层,则为室内导航
+			createPath(cur_map.value!.userData.floor_key, character.getPosition(), next_point.pos);
+			character.is_cross_floor_navigation = false;
+		}
+	} else {
+		controls.enabled = true;
+	}
+};
+
+character.addEventListener("on-path-end", handlePathEnd);
+
+const {
+	findNavPath,
+	getConnectPointKeyMap,
+	getPathPointPosition
+} = useFloorConnectFinder(map_conn_points, graph);
+
+// 全部连通点Key所属楼层映射
+const connect_floor_map = getConnectPointKeyMap();
+
+const path_finder = new PathFinder();
+
+const texture_loader = new TextureLoader();
+let cycle_light_texture: Texture | undefined;
+texture_loader.load(cycle_light_src, (texture) => {
+	cycle_light_texture = texture;
+	cycle_light_texture.wrapS = RepeatWrapping;
+	cycle_light_texture.repeat.set(2, 1);
+});
+
+const {removeConnectPoint, renderConnectPoint} = useConnectPoint(scene);
+
+// 切换地图
+const setfloorId = async (floor_key: string): Promise<void> => {
+	clearMap();
+
+	renderConnectPoint(map_conn_points.get(floor_key)!, graph.getAdjList(), cycle_light_texture as Texture);
+
+	let map: Group;
+
+	if (map_file_cache[floor_key]) {
+		map = map_file_cache[floor_key];
+	} else {
+		map = (await useGLTFLoader(Floor[floor_key])).scene;
+		map.userData.floor_key = floor_key;
+		map_file_cache[floor_key] = map;
+	}
+
+	map.traverse(item => {
+		if (isMesh(item) && item.name.includes("NavMesh")) {
+			item.material.visible = false;
+			path_finder.setZoneData(floor_key, item);
+		}
+	});
+
+	cur_map.value = map;
+
+	scene.add(map);
+
+	return Promise.resolve();
+};
+
+// 室内导航方案(会初始化地图)
+const insideNavigation = (zone: string, start_pos: Vector3, end_pos: Vector3) => {
+	controls.enabled = false;
+
+	character.setPathLineColor(`rgb(${getRandomIntInclusive(0, 255)}, ${getRandomIntInclusive(0, 255)}, ${getRandomIntInclusive(0, 255)})`);
+
+	setfloorId(zone)
+		.then(() => {
+			createPath(zone, start_pos, end_pos);
+		});
+};
+
+// 单地图两点间的导航路径(不会初始化地图)
+const createPath = (zone: string, start_pos: Vector3, end_pos: Vector3) => {
+	const path = path_finder.queryPath(zone, start_pos, end_pos);
+	character.addToScene(start_pos);
+	path.unshift(start_pos);
+	character.setPath(path);
+};
+
+let cross_floor_path: ReturnType<typeof getPathPointPosition> = [];
+
+// 多楼层地图导航
+const multiFloorNavigation = (paths: string[], start_floor: string, start_pos: Vector3, end_pos: Vector3) => {
+	controls.enabled = false;
+
+	setfloorId(start_floor)
+		.then(() => {
+			character.setPathLineColor(`rgb(${getRandomIntInclusive(0, 255)}, ${getRandomIntInclusive(0, 255)}, ${getRandomIntInclusive(0, 255)})`);
+
+			character.is_cross_floor_navigation = true;
+
+			// 将字符串路径转换为具体的坐标点路径
+			cross_floor_path = getPathPointPosition(paths);
+
+			cross_floor_path.unshift({
+				key: "start_inside",
+				pos: start_pos
+			});
+
+			cross_floor_path.push({
+				key: "end_inside",
+				pos: end_pos
+			});
+
+			const start_point = cross_floor_path.shift();
+			const next_conn_point = cross_floor_path.shift();
+
+			// 起始坐标跟第一个连通点一定是同一个地图
+			createPath(start_floor, start_point!.pos, next_conn_point!.pos);
+		});
+};
+
+// 跨地图连通点查询(找到全部路径并过滤掉不必要的路径)
+const findMultiFloorNavPath  = (start_floor: string, end_floor: string, start_pos: Vector3) => {
+	return findNavPath(start_floor, end_floor, start_pos);
+};
+
+const setCharacterPosition = (pos: Vector3) => {
+	character.addToScene(pos);
+};
+
+renderer.setAnimationLoop(() => {
+	const delta = clock.getDelta();
+	renderer.render(scene, camera);
+	renderCSS2D(scene, camera);
+
+	character.update(delta);
+
+	if (cycle_light_texture) {
+		cycle_light_texture.offset.x -= 0.005;
+	}
+});
+
+// 清空地图和人物
+const clearMap = () => {
+	if (cur_map.value) {
+		cur_map.value.traverse(item => {
+			if (isMesh(item)) {
+				item.material.dispose();
+			}
+		});
+
+		scene.remove(cur_map.value);
+		cur_map.value = null;
+	}
+	removeConnectPoint();
+	character.removeToScene();
+	character.clearPath();
+};
+
+defineExpose({
+	setfloorId,
+	insideNavigation,
+	multiFloorNavigation,
+	clearMap,
+	setCharacterPosition,
+	setFullScreenSize,
+	findMultiFloorNavPath
+});
+</script>
+
+<style scoped>
+.nav-map {
+	border: 1px solid #ccc;
+	overflow: hidden;
+}
+</style>

+ 234 - 0
src/views/threeMap/components/ThreeMap/ThreeMap.vue

@@ -0,0 +1,234 @@
+<template>
+    <div ref="map_container_ref" :class="['three-map', props.className]" :style="{
+        width: props.width,
+        height: props.height
+    }" />
+</template>
+
+<script setup lang="ts">
+import { useInitThree } from "./useInitThree";
+import { ref, watch, onMounted, shallowRef, inject } from "vue";
+import { useGLTFLoader } from "@/hooks/useGLTFLoader";
+import {
+    Group,
+    Mesh,
+    MeshBasicMaterial,
+    Vector3,
+    SphereGeometry,
+    Sprite,
+    TextureLoader,
+    Texture, RepeatWrapping, Box3
+} from "three";
+import { useRayCast } from "./useRayCast";
+import { coordinate_src, cycle_light_src, Floor } from "./Constants";
+import { createImgSprite, createSphere, useConnectPoint } from "@/hooks/useMesh";
+import { getRandomIntInclusive, isMesh } from "@/hooks/Utils";
+import { connect_point_inject_key } from "../../global";
+import { Character } from "../Character/Character";
+import { character_src } from "../Character/Constants";
+import { PathFinder } from "../PathFinder/PathFinder";
+
+const props = defineProps<{
+    className: string,
+    width: string,
+    height: string,
+    floorId: string,
+}>();
+const {
+    map_conn_points,
+    graph
+} = inject(connect_point_inject_key)!;
+
+// 坐标点texture
+const texture_loader = new TextureLoader();
+let coordinate_texture: Texture | undefined;
+texture_loader.load(coordinate_src, (texture) => {
+    coordinate_texture = texture;
+});
+
+// 连通点texture
+let cycle_light_texture: Texture | undefined;
+texture_loader.load(cycle_light_src, (texture) => {
+    cycle_light_texture = texture;
+    cycle_light_texture.wrapS = RepeatWrapping;
+    cycle_light_texture.repeat.set(2, 1);
+});
+
+const cur_map = shallowRef<Group | null>(null);
+
+const map_file_cache: Record<string, Group> = {};
+
+// 选择的坐标点Mesh(起始点/终点)
+const startPoint = shallowRef<Group | null>(null);
+
+const endPoint = shallowRef<Group | null>(null);
+
+let ray_cast: ReturnType<typeof useRayCast> | null = null;
+
+const map_container_ref = ref<HTMLElement>();
+
+const {
+    scene,
+    camera,
+    renderer,
+    clock,
+    controls,
+    renderCSS2D
+} = useInitThree(map_container_ref);
+const character = new Character(camera, scene, character_src, new Vector3(0, 0, 0));
+
+const path_finder = new PathFinder();
+// 连通点
+const { renderConnectPoint } = useConnectPoint(scene);
+
+// 切换地图
+const setfloorId = async (scene_url: string) => {
+    if (cur_map.value) {
+        cur_map.value.traverse(item => {
+            if (isMesh(item)) {
+                item.material.dispose();
+            }
+        });
+
+        clearPointHelper();
+
+        scene.remove(cur_map.value);
+    }
+
+    let map: Group;
+
+    if (map_file_cache[scene_url]) {
+        map = map_file_cache[scene_url];
+    } else {
+        map = (await useGLTFLoader(Floor[scene_url])).scene;
+        map_file_cache[scene_url] = map;
+    }
+
+    map.traverse(item => {
+        console.log(item);
+        
+        if (isMesh(item) && item.name.includes("NavMesh")) {
+            // item.material.visible = false;
+            path_finder.setZoneData(props.floorId, item);
+        } 
+    });
+
+    cur_map.value = map;
+    scene.add(map);
+    const bbox = new Box3().setFromObject(map);
+    const center = new Vector3();
+    bbox.getCenter(center);
+    console.log(center);
+    // 将模型的位置设置为原点
+
+    // 设置控制器目标点为模型中心
+    controls.target.copy(center);
+    camera.position.copy(center).add(new Vector3(0, 40, 50)); //3d视角
+    // camera.position.copy(center).add(new Vector3(0, 70, 0)); //2d视角
+    controls.update();
+
+    // renderConnectPoint(map_conn_points.get(scene_url)!, graph.getAdjList(), cycle_light_texture as Texture);
+
+    if (ray_cast) {
+        if (!ray_cast.intersect.value) {
+            ray_cast.onRayCast(map, handleMapClick);
+        } else {
+            ray_cast.setIntersect(map);
+        }
+    }
+};
+
+onMounted(() => {
+    ray_cast = useRayCast(map_container_ref.value as HTMLElement, camera);
+
+    watch(() => props.floorId, setfloorId, { immediate: true });
+});
+
+const clearPointHelper = () => {
+    if (startPoint.value) {
+        // const sphere = select_point.value.getObjectByName("sphere") as Mesh<SphereGeometry, MeshBasicMaterial>;
+        const sprite = startPoint.value.getObjectByName("sprite") as Sprite;
+        // sphere.material.dispose();
+        sprite.material.dispose();
+        scene.remove(startPoint.value);
+
+        startPoint.value = null;
+    }
+    if (endPoint.value) {
+        // const sphere = select_point.value.getObjectByName("sphere") as Mesh<SphereGeometry, MeshBasicMaterial>;
+        const sprite = endPoint.value.getObjectByName("sprite") as Sprite;
+        // sphere.material.dispose();
+        sprite.material.dispose();
+        scene.remove(endPoint.value);
+        endPoint.value = null;
+        character.removeToScene();
+        character.clearPath();
+    }
+};
+
+const renderPointHelper = (point: Vector3) => {
+    if (startPoint.value && endPoint.value) clearPointHelper();
+
+    const group = new Group();
+    group.userData.position = point;
+    // const sphere = createSphere(.1, 0xff9900, point);
+    const sprite = createImgSprite(
+        coordinate_texture as Texture,
+        point,
+        0,
+        0.03
+    );
+    // group.add(sphere);
+    group.add(sprite);
+    scene.add(group);
+    if (startPoint.value) {
+        endPoint.value = group;
+        insideNavigation(props.floorId, startPoint.value.userData.position, endPoint.value.userData.position);
+        console.log(startPoint.value.userData.position);
+        console.log(endPoint.value.userData.position);
+    } else {
+        startPoint.value = group;
+    }
+
+};
+
+// 室内导航方案(会初始化地图)
+const insideNavigation = (zone :string, start_pos: Vector3, end_pos: Vector3) => {
+    // controls.enabled = false;
+
+    // character.setPathLineColor(`rgb(${getRandomIntInclusive(0, 255)}, ${getRandomIntInclusive(0, 255)}, ${getRandomIntInclusive(0, 255)})`);
+
+    createPath(zone, start_pos, end_pos);
+};
+// 单地图两点间的导航路径(不会初始化地图)
+const createPath = (zone: string, start_pos: Vector3, end_pos: Vector3) => {
+    const path = path_finder.queryPath(zone, start_pos, end_pos);
+    character.addToScene(start_pos);
+    path.unshift(start_pos);
+    character.setPath(path);
+};
+const handleMapClick = (point: Vector3) => {
+    renderPointHelper(point);
+};
+
+renderer.setAnimationLoop(() => {
+    const delta = clock.getDelta();
+    renderer.render(scene, camera);
+    if (cycle_light_texture) {
+        cycle_light_texture.offset.x -= 0.005;
+    }
+    renderCSS2D(scene, camera);
+});
+
+defineExpose({
+    startPoint,
+    clearPointHelper
+});
+</script>
+
+<style scoped lang="scss">
+.three-map {
+    border: 1px solid #ccc;
+    overflow: hidden;
+}
+</style>

+ 29 - 0
src/views/threeMap/components/ThreeMap/useCSS2D.ts

@@ -0,0 +1,29 @@
+import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer.js";
+import type {PerspectiveCamera, Scene} from "three";
+
+export const useCSS2D = () => {
+	// 实例化CSS2D渲染器
+	const cssRenderer = new CSS2DRenderer();
+	cssRenderer.domElement.style.position = "absolute";
+	cssRenderer.domElement.style.zIndex = "999";
+	cssRenderer.domElement.style.left = "0";
+	cssRenderer.domElement.style.top = "0";
+	cssRenderer.domElement.style.pointerEvents = "none";
+
+	const appendCSS2D = (dom_element: HTMLElement) => {
+		const width = dom_element.clientWidth;
+		const height = dom_element.clientHeight;
+		cssRenderer.setSize(width, height);
+		dom_element.appendChild(cssRenderer.domElement);
+	};
+
+	const renderCSS2D = (scene: Scene, camera: PerspectiveCamera) => {
+		cssRenderer.render(scene, camera);
+	};
+
+	return {
+		appendCSS2D,
+		renderCSS2D,
+		cssRenderer
+	};
+};

+ 95 - 0
src/views/threeMap/components/ThreeMap/useInitThree.ts

@@ -0,0 +1,95 @@
+import {
+	Color,
+	PerspectiveCamera,
+	Scene,
+	WebGLRenderer,
+	AxesHelper,
+	Clock,
+	AmbientLight,
+    Group
+} from "three";
+import {OrbitControls} from "three/examples/jsm/controls/OrbitControls.js";
+import {nextTick, onMounted, type Ref} from "vue";
+import {useCSS2D} from "./useCSS2D";
+
+export const useInitThree = (container: Ref) => {
+	const {appendCSS2D, renderCSS2D, cssRenderer} = useCSS2D();
+
+    const scene = new Scene();
+	scene.background = new Color(0xffffff);
+
+	scene.add(new AmbientLight(0xffffff));
+
+	scene.add(new AxesHelper(10));
+
+	const camera = new PerspectiveCamera(
+		30,
+		window.innerWidth / window.innerHeight,
+		0.1,
+		1000
+	);
+    // 设置相机位置
+    camera.position.set(0, 50,0); // 向后移动相机
+    camera.lookAt(scene.position); // 确保相机看向场景中心
+
+	const renderer = new WebGLRenderer({antialias: true});
+	renderer.shadowMap.enabled = true;
+
+	const controls = new OrbitControls(camera, renderer.domElement);
+	controls.minDistance = 5;
+    controls.maxPolarAngle = Math.PI / 2.1;
+    // controls.enableRotate = false
+    controls.target.set(0, 0, 0); // 明确设置目标点为原点
+    controls.update(); // 立即应用控制参数
+	/* controls.mouseButtons = {
+		LEFT: MOUSE.PAN,
+		MIDDLE: MOUSE.DOLLY,
+		RIGHT: MOUSE.ROTATE
+	};*/
+
+	const clock = new Clock();
+
+	onMounted(() => {
+		nextTick(() => {
+			if (container.value) {
+				const width = container.value.clientWidth;
+				const height = container.value.clientHeight;
+
+				// appendCSS2D(container.value);
+
+				camera.aspect = width / height;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize(width, height);
+				container.value.appendChild(renderer.domElement);
+
+				window.addEventListener("resize", () => {
+					resizeRenderer();
+				});
+			}
+		});
+	});
+
+	const setFullScreenSize = () => {
+		container.value.requestFullscreen().then(() => {
+			resizeRenderer(window.innerWidth, window.innerHeight);
+		});
+	};
+
+	const resizeRenderer = (width = container.value.clientWidth, height = container.value.clientHeight) => {
+		camera.aspect = width / height;
+		camera.updateProjectionMatrix();
+		renderer.setSize(width, height);
+		cssRenderer.setSize(width, height);
+	};
+
+	return {
+		scene,
+		camera,
+		renderer,
+		controls,
+        clock,
+		setFullScreenSize,
+		renderCSS2D
+	};
+};

+ 40 - 0
src/views/threeMap/components/ThreeMap/useRayCast.ts

@@ -0,0 +1,40 @@
+import {Group, Mesh, PerspectiveCamera, Raycaster, Vector2, Vector3} from "three";
+
+type Intersect = Group | Mesh | null
+
+export const useRayCast = (dom_element: HTMLElement, camera: PerspectiveCamera) => {
+	const mouse = new Vector2();
+	const raycaster = new Raycaster();
+	const intersect: {value: Intersect} = {
+		value: null
+	};
+
+	const onRayCast = (intersect_navmesh: Intersect, onHit: (point: Vector3) => void) => {
+		intersect.value = intersect_navmesh;
+
+		dom_element.addEventListener("click", (e) => {
+			if (intersect.value === null) return;
+
+			mouse.x = (e.offsetX / dom_element.clientWidth) * 2 - 1;
+			mouse.y = -(e.offsetY / dom_element.clientHeight) * 2 + 1;
+
+			raycaster.setFromCamera(mouse, camera);
+
+			const intersects = raycaster.intersectObject(intersect.value);
+
+			if (intersects.length) {
+				onHit(intersects[0].point);
+			}
+		});
+	};
+
+	const setIntersect = (intersect_navmesh: Group | Mesh | null) => {
+		intersect.value = intersect_navmesh;
+	};
+
+	return {
+		onRayCast,
+		setIntersect,
+		intersect
+	};
+};

+ 8 - 0
src/views/threeMap/global.ts

@@ -0,0 +1,8 @@
+import type {InjectionKey} from "vue";
+import type {initConnectPoint} from "./components/PathFinder/initConnectPoint";
+
+// 地图连接点和图结构的Inject Key
+export const connect_point_inject_key: InjectionKey<{
+	map_conn_points: ReturnType<typeof initConnectPoint>["map_conn_points"],
+	graph: ReturnType<typeof initConnectPoint>["graph"],
+}> = Symbol();

+ 24 - 0
src/views/threeMap/index.vue

@@ -0,0 +1,24 @@
+<template>
+    <div class="container">
+        <three-map ref="start_map_ref" class-name="start-map" width="100%" height="100%" floor-id="floor_9" />
+    </div>
+</template>
+<script setup lang="ts">
+import { onMounted, provide, ref, shallowRef, watch } from "vue";
+import ThreeMap from "./components/ThreeMap/ThreeMap.vue";
+import { connect_point_inject_key } from "./global";
+import { initConnectPoint } from "./components/PathFinder/initConnectPoint";
+const { map_conn_points, graph } = initConnectPoint();
+provide(connect_point_inject_key, {
+    map_conn_points,
+    graph
+});
+
+
+</script>
+<style lang="scss" scoped>
+.container{
+    width: 100%;
+    height: 100vh;
+}
+</style>

+ 7 - 0
src/vite-env.d.ts

@@ -0,0 +1,7 @@
+// 解决模块引用提示错误的问题
+/// <reference types="vite/client" />
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<{}, {}, any>
+  export default component
+}

+ 38 - 0
tsconfig.json

@@ -0,0 +1,38 @@
+{
+    "compilerOptions": {
+        "target": "ESNext",
+        "useDefineForClassFields": true,
+        "module": "ESNext",
+        "moduleResolution": "Node",
+        "strict": false,
+        "jsx": "preserve",
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "esModuleInterop": true,
+        "lib": [
+            "ESNext",
+            "DOM"
+        ],
+        "skipLibCheck": true,
+        "noEmit": true,
+        "baseUrl": "./",  // 解析非相对模块的基础地址,默认是当前目录
+        "paths": {
+            "@/*": [
+                "src/*"
+            ],
+        }
+    },
+    "include": [
+        "src/**/*.ts",
+        "src/**/*.d.ts",
+        "src/**/*.tsx",
+        "src/**/*.vue",
+        "auto-imports.d.ts",
+        "components.d.ts"
+    ],
+    "references": [
+        {
+            "path": "./tsconfig.node.json"
+        }
+    ]
+}

+ 9 - 0
tsconfig.node.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 44 - 0
vite.config.ts

@@ -0,0 +1,44 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { viteMockServe } from 'vite-plugin-mock'
+import components from 'unplugin-vue-components/vite'
+import { VantResolver } from 'unplugin-vue-components/resolvers'
+import eslintPlugin from 'vite-plugin-eslint'
+// https://vitejs.dev/config/
+export default ({ command }) => defineConfig({
+    css: {
+        preprocessorOptions: {
+            scss: {
+                api: 'modern-compiler'
+            }
+        }
+    },
+    plugins: [
+        vue(),
+        viteMockServe({
+            mockPath: 'mock',
+            localEnabled: command === 'serve',
+        }),
+        components({
+            resolvers: [VantResolver()]
+        }),
+        eslintPlugin()
+    ],
+
+    resolve: {
+        alias: {
+            '@': '/src'
+        }
+    },
+    server: {
+        host: "0.0.0.0",
+        port: 1992,
+        // proxy: {
+        //   '/api': {
+        //     target: 'http://127.0.0.1:2020',
+        //     changeOrigin: true,
+        //     rewrite: (path) => path.replace('/api', '')
+        //   }
+        // }
+    }
+})

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott