案例效果截圖如下:
具體案例場景和功能,詳見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>