案例效果截图如下:
具体案例场景和功能,详见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>