three.js+vue智慧社区web3d数字孪生三维地图
案例效果截图如下:
具体案例场景和功能,详见b站视频:
https://www.bilibili.com/video/BV1Bb421E7WL/?vd_source=7d4ec9c9275b9c7d16afe9b4625f636c
案例场景逻辑代码:
<template> <div id="whole"> <!-- threejs容器 --> <div id="three" ref="container"></div> <!-- 搜索框 --> <div id="search" v-if="props.itemType === '房屋数据'"> <a-input v-model:value="searchValue" placeholder="楼栋搜索" id="searchFrame" style="width: 100%; height: 4vh" @input="searchChange" /> <div id="searchContent" v-show="searchData.length > 0"> <div v-for="(val, index) in searchData" :key="index" id="searchItem" @click="viewAngleZoomIn(val)">{{ val }}</div> </div> </div> <!-- 建筑标记元素 --> <div id="buildMarker" ref="buildMarker" style="display: none"> <div id="content">1幢</div> </div> <!-- 楼栋点击弹出框 --> <div id="popup" ref="popup" style="display: none"> <div id="head"> <div id="title">{{ popupTitle }}</div> <div id="close" @click="popupClose"></div> </div> <div id="content"> <div class="common" @click="popupClick('1单元')">1单元</div> <div class="common" @click="popupClick('2单元')">2单元</div> <div class="common" @click="popupClick('3单元')">3单元</div> <div class="common" @click="popupClick('4单元')">4单元</div> </div> </div> </div> <!-- 楼栋单元信息弹框 --> <infoPopFrame :building="popupTitle" :buildingUnit="buildingUnit" :visible="infoPopFrameVisible" :baseInfo="buildingBaseInfo" :floorData="floorData" @closePopFrame="infoPopFrameVisible = false" ></infoPopFrame> </template> <script lang="ts" setup> import * as THREE from 'three'; import TWEEN from '@tweenjs/tween.js'; import { onMounted, ref, onUnmounted } from 'vue'; import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; import { initBaseConfig } from './components/threeBaseConfig'; import { loadingModel } from './components/modelImport.js'; import { disposeObject } from './components/disposeObject.js'; import infoPopFrame from './components/infoPopFrame.vue'; // 父组件传值 const props = defineProps({ // 项目类型:总览/房屋数据 itemType: { type: String, }, }); // threejs基础配置 let scene, camera, renderer, controls, css2DRenderer; // threejs画布容器 const container = ref(); // 弹框标题 const popupTitle = ref(''); // 弹框元素 const popup = ref(); // 2D弹框 let cSS2DPopup; // 建筑标记元素 const buildMarker = ref(); // 搜索框输入值 const searchValue = ref(''); // 小区建筑模型 let model = null; // 搜索框检索到的数据 const searchData = ref([]); // 建筑名称数据,用于匹配搜索框的值searchValue const buildNameData = []; // 建筑单元 const buildingUnit = ref(''); // 建筑单元基本信息 const buildingBaseInfo = ref([ { name: '产权人', value: '于晓敏' }, { name: '商铺', value: 2 }, { name: '自住房间', value: 3 }, { name: '租住房间', value: 12 }, { name: '常驻人口', value: 4 }, { name: '流动人口', value: 8 }, ]); // 建筑信息弹框显示 const infoPopFrameVisible = ref(false); // 建筑楼层数据 const floorData = ref([ { name: '一楼', houseNumArr: [ { num: '1-101', type: '商铺' }, { num: '1-102', type: '商铺' }, { num: '1-103', type: '商铺' }, { num: '1-104', type: '商铺' }, ], }, { name: '二楼', houseNumArr: [ { num: '2-101', type: '租住' }, { num: '2-102', type: '租住' }, { num: '2-103', type: '租住' }, { num: '2-104', type: '租住' }, ], }, { name: '三楼', houseNumArr: [ { num: '3-101', type: '租住' }, { num: '3-102', type: '租住' }, { num: '3-103', type: '租住' }, { num: '3-104', type: '租住' }, ], }, { name: '四楼', houseNumArr: [ { num: '4-101', type: '自住' }, { num: '4-102', type: '自住' }, { num: '4-103', type: '自住' }, { num: '4-104', type: '自住' }, ], }, { name: '五楼', houseNumArr: [ { num: '5-101', type: '自住' }, { num: '5-102', type: '自住' }, { num: '5-103', type: '自住' }, { num: '5-104', type: '自住' }, ], }, ]); // 组件卸载时清除场景scene中的所有内容,释放资源 onUnmounted(() => { disposeObject(scene); }); // 组件挂载完成,进行初始化 onMounted(async () => { // 初始化基础配置:场景、相机、渲染器等 const baseConfig = initBaseConfig(); scene = baseConfig.scene; camera = baseConfig.camera; renderer = baseConfig.renderer; controls = baseConfig.controls; css2DRenderer = baseConfig.css2DRenderer; // 渲染器dom挂在threejs容器中 container.value.appendChild(renderer.domElement); container.value.appendChild(css2DRenderer.domElement); // 加载3D模型 model = await loadingModel(); scene.add(model); // 初始化css2D弹框 initPopup(); // 添加鼠标移动事件 addMouseMoveEvent(); // 添加鼠标点击事件 addMouseClickEvent(); // 添加建筑的标记 addBuildMarker(); if (props.itemType === '房屋数据') { // 获取建筑名称数据,用以搜索框检索 model.getObjectByName('建筑').traverse((item) => { if (item.isMesh && item.name && item.name.includes('幢')) { if (buildNameData.includes(item.name)) return; buildNameData.push(item.name); } }); } // 开始循环渲染 render(); }); // 循环渲染 const render = () => { requestAnimationFrame(render); TWEEN.update(); controls.update(); css2DRenderer.render(scene, camera); renderer.render(scene, camera); }; // 射线检测 const rayTest = (e) => { const px = e.offsetX; const py = e.offsetY; // 屏幕坐标转为标准设备坐标 const x = (px / window.innerWidth) * 2 - 1; const y = -(py / (window.innerHeight - 36 - 56)) * 2 + 1; // 创建射线 const raycaster = new THREE.Raycaster(); // 设置射线参数 raycaster.setFromCamera(new THREE.Vector2(x, y), camera); // 射线交叉计算拾取模型 let intersects = raycaster.intersectObjects(model.getObjectByName('建筑').children); return intersects; }; // 鼠标移动事件,释放射线进行检测建筑模型,改变检测到的建筑模型的颜色 const moveEvent = (e) => { const intersects = rayTest(e); // 所有建筑模型发射光emissive重置黑色 model.getObjectByName('建筑').traverse((item) => { if (item.isMesh) { item.material.emissive = new THREE.Color('#000'); } }); // 检测结果存在时 if (intersects[0]) { // 改变鼠标样式为手指 document.body.style.cursor = 'pointer'; // 当前检测建筑模型 const currentBuildModel = intersects[0].object; // 定义材质颜色 currentBuildModel.material.emissive = new THREE.Color('#00BFFF'); } else { // 恢复默认鼠标样式 document.body.style.cursor = 'default'; } }; // 鼠标点击事件,释放射线进行检测建筑模型 const clickEvent = (e) => { const intersects = rayTest(e); // 检测结果存在时 if (intersects[0]) { // 过滤掉其他建筑 if (intersects[0].object.name.includes('其他')) return; if (intersects[0].object.name.includes('配电')) { popupTitle.value = '配电'; } else { popupTitle.value = intersects[0].object.name; } model.getObjectByName('建筑').traverse((item) => { if (item.isMesh) { item.material.color = item.color; } }); intersects[0].object.material.color = new THREE.Color('#00C5CD'); cSS2DPopup.visible = true; controls.update(); cSS2DPopup.position.copy(controls.target); } }; // 初始css2D弹框,将弹框元素转换成threejs中的css2D对象 function initPopup() { popup.value.style.display = 'block'; cSS2DPopup = new CSS2DObject(popup.value); cSS2DPopup.renderOrder = 99; cSS2DPopup.visible = false; cSS2DPopup.position.set(0, 0, 0); scene.add(cSS2DPopup); } // 添加鼠标移动事件 function addMouseMoveEvent() { // 节流函数 const throttleChange = throttle(moveEvent, 10); // 监听鼠标移动事件 container.value.addEventListener('mousemove', (e) => { throttleChange(e); }); } // 节流函数,鼠标移动事件触发太过频繁需要节制触发次数 function throttle(func, limit) { let inThrottle; return function () { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => (inThrottle = false), limit); } }; } // 添加鼠标点击事件 function addMouseClickEvent() { // 监听鼠标点击事件 container.value.addEventListener('click', (e) => { clickEvent(e); }); } // 视角拉近 function viewAngleZoomIn(val) { cSS2DPopup.visible = false; // 当前目标建筑模型 const target = model.getObjectByName(val); // 重置所有建筑模型颜色 model.getObjectByName('建筑').traverse((item) => { if (item.isMesh) { item.material.color = item.color; } }); // 设置建筑模型颜色 target.material.color = new THREE.Color('#00C5CD'); // 目标位置 const targetPos = target.getWorldPosition(new THREE.Vector3()); // 移动位置 const movePos = targetPos.clone(); movePos.y += 80; movePos.z += 55; // 开始位置 const startPos = camera.position.clone(); // 初始的控件目标 const initialTarget = controls.target.clone(); new TWEEN.Tween({ t: 0 }) .to({ t: 1 }, 1500) .easing(TWEEN.Easing.Sinusoidal.InOut) .onUpdate(function (e) { const t = e.t; camera.position.lerpVectors(startPos, movePos, t); controls.target.lerpVectors(initialTarget, targetPos, t); camera.updateProjectionMatrix(); controls.update(); }) .onComplete(function () { cSS2DPopup.visible = true; popupTitle.value = val; controls.update(); cSS2DPopup.position.copy(controls.target); }) .start(); } // 添加建筑标记 function addBuildMarker() { model.getObjectByName('建筑').traverse((item) => { if (item.name.includes('其他')) return; if (item.isMesh) { let closeDom; if (item.name.includes('配电')) { closeDom = buildMarker.value.cloneNode(true); closeDom.style.width = '2vw'; closeDom.children[0].innerHTML = '配电'; } else { closeDom = buildMarker.value.cloneNode(true); closeDom.style.width = `${item.name.length * 0.7}vw`; closeDom.children[0].innerHTML = item.name; } const cSS2DObject = new CSS2DObject(closeDom); const pos = item.getWorldPosition(new THREE.Vector3()); cSS2DObject.position.copy(pos); cSS2DObject.position.y += 5; cSS2DObject.name = item.name + '标记'; scene.add(cSS2DObject); } }); } // 搜索框内容变化事件,模糊匹配建筑名称数据 function searchChange(e) { if (!e) { searchData.value = []; return; } // 匹配结果 const rel = buildNameData.filter((item) => item.includes(e)); // 对匹配结果进行排序 rel.sort((a, b) => { // 提取数值 const getNumber = (str) => parseInt(str.match(/\d+/)[0]); // 检测名称中是否带有别墅 const isVilla = (str) => str.includes('别墅'); if (isVilla(a) && isVilla(b)) { return getNumber(a) - getNumber(b); } else if (isVilla(a)) { return 1; } else if (isVilla(b)) { return -1; } else { return getNumber(a) - getNumber(b); } }); searchData.value = rel; } // 弹框关闭事件 function popupClose() { cSS2DPopup.visible = false; model.getObjectByName('建筑').traverse((item) => { if (item.isMesh) { item.material.color = item.color; } }); } // 弹框点击事件 function popupClick(e) { buildingUnit.value = e; infoPopFrameVisible.value = true; } </script> <style lang="less" scoped> body { font-size: 0.7vw; } ::v-deep .arco-card-body { padding: 0px !important; width: 100%; height: 100%; } ::v-deep .arco-input-wrapper { border-radius: 10px; background: #000; border: 1px solid #009acd; color: #fafafa; } ::v-deep .arco-input-wrapper .arco-input.arco-input-size-medium { font-size: 0.7vw !important; } /* 当视口宽度小于 1400 像素时,设置最小字体大小 */ @media (max-width: 1400px) { #buildMarker { font-size: 12px !important; width: 55px !important; height: 20px !important; } } #whole { width: 100%; height: calc(100% - 36px - 56px); #three { width: 100%; height: 100%; } #search { z-index: 999; position: absolute; width: 22vw; right: 1%; top: 3%; #searchContent { border-radius: 4px; margin-top: 4px; width: 100%; max-height: 300px; border: 1px solid #0e2346; background: rgba(0, 0, 0, 0.7); #searchItem { text-indent: 1em; line-height: 2.5vh; font-size: 0.7vw; width: 100%; height: 2.5vh; color: #eee; border-bottom: 1px solid #12485a; } #searchItem:hover { cursor: pointer; background: #0e2346; } } #searchContent { overflow-y: auto; } #searchContent::-webkit-scrollbar { width: 4px; } #searchContent::-webkit-scrollbar-thumb { border-radius: 10px; background: rgba(30, 150, 200, 0.7); } #searchContent::-webkit-scrollbar-track { border-radius: 0; background: rgba(0, 0, 0, 0.1); } } #buildMarker { z-index: 997; position: absolute; top: 0; left: 0; font-size: 0.6vw; height: 2.5vh; display: flex; flex-direction: column; justify-content: center; align-items: center; #content { width: 100%; height: 100%; background: #0e2346; border: 1px solid #6298a9; display: flex; align-items: center; justify-content: center; color: #fafafa; #mapTag_value { color: #ffd700; } } } #popup { z-index: 999; position: absolute; top: 0; left: 0; width: 20vw; height: 15vh; background: rgba(15, 41, 77, 0.85); border-radius: 0.3vw; border: 1px solid rgba(10, 109, 155, 0.95); #head { width: 95%; margin-left: 2.5%; height: 30%; border-bottom: 1px solid #009acd; display: flex; align-items: center; justify-content: space-between; #title { font-size: 0.85vw; color: #bbffff; margin-left: 2.5%; } #close { pointer-events: all; width: 1vw; height: 1vw; background: url('../../assets/close.png') no-repeat; background-size: 100% 100%; } #close:hover { cursor: pointer; } } #content { width: 95%; margin-left: 2.5%; height: 70%; display: flex; justify-content: space-evenly; align-items: center; .common { font-size: 0.7vw; display: flex; justify-content: center; align-items: center; pointer-events: all; width: 18%; height: 3vh; border: 1px solid #1f81a1; color: #fafafa; } .common:hover { cursor: pointer; color: #bbffff; border: 1px solid #03c0ff; } } } } </style>