文章目录
- 序
- 一、1.0.0版本
- 1.新增
- 2.编辑
- 3.导出
- 4.导入
- 二、2.0.0版本
- 1. 修复模型垂直方向放置时 模型会重合
- 4. 修复了导出导入功能 现在是1:1导出导入
- 5. 新增一个地面 视角看不到地下 设置了禁止编辑地面 地面设置为圆形
- 6. 新增功能 可选择基本圆形 方形 圆柱形等模型以及可放置自己的模型文件
- 7. 优化面板样式
- 总结
序
要实现一个类似于数字孪生的场景 可以在线、新增、删除模型 、以及编辑模型的颜色、长宽高
然后还要实现 编辑完后 保存为json数据 记录模型数据 既可以导入也可以导出
一、1.0.0版本
1.新增
先拿建议的立方体来代替模型
点击新增按钮就新增一个立方体
2.编辑
点击编辑按钮可以修改坐标 长宽高 颜色等等信息
3.导出
点击导出按钮 可以导出为json数据格式
4.导入
选择导入刚才的json文件
有一个bug 就是导入后颜色丢失了 点击模型 信息面板的颜色显示正常 渲染颜色丢失
源码
<template><div id="app" @click="onAppClick"><div id="info"><button @click.stop="addBuilding">新增</button><button @click.stop="showEditor">编辑</button><button @click.stop="exportModelData">导出</button><input type="file" @change="importModelData" ref="fileInput" /></div><div id="editor" v-if="editorVisible" @click.stop><h3>Edit Building</h3><label for="color">Color:</label><input type="color" id="color" v-model="selectedObjectProps.color" /><br /><label for="posX">Position X:</label><inputtype="number"id="posX"v-model="selectedObjectProps.posX"step="0.1"/><br /><label for="posY">Position Y:</label><inputtype="number"id="posY"v-model="selectedObjectProps.posY"step="0.1"/><br /><label for="posZ">Position Z:</label><inputtype="number"id="posZ"v-model="selectedObjectProps.posZ"step="0.1"/><br /><label for="scaleX">Scale X:</label><inputtype="number"id="scaleX"v-model="selectedObjectProps.scaleX"step="0.1"/><br /><label for="scaleY">Scale Y:</label><inputtype="number"id="scaleY"v-model="selectedObjectProps.scaleY"step="0.1"/><br /><label for="scaleZ">Scale Z:</label><inputtype="number"id="scaleZ"v-model="selectedObjectProps.scaleZ"step="0.1"/><br /><label for="rotX">Rotation X:</label><inputtype="number"id="rotX"v-model="selectedObjectProps.rotX"step="0.1"/><br /><label for="rotY">Rotation Y:</label><inputtype="number"id="rotY"v-model="selectedObjectProps.rotY"step="0.1"/><br /><label for="rotZ">Rotation Z:</label><inputtype="number"id="rotZ"v-model="selectedObjectProps.rotZ"step="0.1"/><br /><button @click="applyEdit">保存</button><button @click="deleteBuilding">删除</button></div><div ref="canvasContainer" style="width: 100vw; height: 100vh"></div></div>
</template><script>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";export default {data() {return {editorVisible: false,selectedObject: null,selectedObjectProps: {color: "#00ff00",posX: 0,posY: 0,posZ: 0,scaleX: 1,scaleY: 1,scaleZ: 1,rotX: 0,rotY: 0,rotZ: 0,},raycaster: null,};},mounted() {this.init();this.animate();window.addEventListener("resize", this.onWindowResize, false);this.loadModelData(); // Load saved model data on page load},methods: {init() {console.log("Initializing Three.js");this.scene = new THREE.Scene();this.scene.background = new THREE.Color(0xcccccc);this.camera = new THREE.PerspectiveCamera(60,window.innerWidth / window.innerHeight,0.1,1000);this.camera.position.set(0, 10, 20);this.renderer = new THREE.WebGLRenderer({ antialias: true });this.renderer.setSize(window.innerWidth, window.innerHeight);this.$refs.canvasContainer.appendChild(this.renderer.domElement);this.controls = new OrbitControls(this.camera, this.renderer.domElement);const light = new THREE.DirectionalLight(0xffffff, 1);light.position.set(5, 10, 7.5);this.scene.add(light);this.raycaster = new THREE.Raycaster();const geometry = new THREE.BoxGeometry(1, 1, 1);const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });this.cube = new THREE.Mesh(geometry, material);this.scene.add(this.cube);},onWindowResize() {this.camera.aspect = window.innerWidth / window.innerHeight;this.camera.updateProjectionMatrix();this.renderer.setSize(window.innerWidth, window.innerHeight);},onAppClick(event) {const mouse = new THREE.Vector2();mouse.x = (event.clientX / window.innerWidth) * 2 - 1;mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;this.raycaster.setFromCamera(mouse, this.camera);const intersects = this.raycaster.intersectObjects(this.scene.children, true);if (intersects.length > 0) {this.selectedObject = intersects[0].object;console.log("Object selected:", this.selectedObject);this.showEditor();}},addBuilding() {const geometry = new THREE.BoxGeometry(1, 1, 1);const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });const building = new THREE.Mesh(geometry, material);building.position.set(Math.random() * 10 - 5, 0.5, Math.random() * 10 - 5);this.scene.add(building);},showEditor() {if (this.selectedObject) {this.editorVisible = true;this.updateEditor(this.selectedObject);}},updateEditor(object) {this.selectedObjectProps.color = `#${object.material.color.getHexString()}`;this.selectedObjectProps.posX = object.position.x;this.selectedObjectProps.posY = object.position.y;this.selectedObjectProps.posZ = object.position.z;this.selectedObjectProps.scaleX = object.scale.x;this.selectedObjectProps.scaleY = object.scale.y;this.selectedObjectProps.scaleZ = object.scale.z;this.selectedObjectProps.rotX = object.rotation.x;this.selectedObjectProps.rotY = object.rotation.y;this.selectedObjectProps.rotZ = object.rotation.z;},applyEdit() {if (this.selectedObject) {const color = this.selectedObjectProps.color;this.selectedObject.material.color.set(color);this.selectedObject.position.set(parseFloat(this.selectedObjectProps.posX),parseFloat(this.selectedObjectProps.posY),parseFloat(this.selectedObjectProps.posZ));this.selectedObject.scale.set(parseFloat(this.selectedObjectProps.scaleX),parseFloat(this.selectedObjectProps.scaleY),parseFloat(this.selectedObjectProps.scaleZ));this.selectedObject.rotation.set(parseFloat(this.selectedObjectProps.rotX),parseFloat(this.selectedObjectProps.rotY),parseFloat(this.selectedObjectProps.rotZ));}},deleteBuilding() {if (this.selectedObject) {this.scene.remove(this.selectedObject);this.selectedObject = null;this.editorVisible = false;}},animate() {requestAnimationFrame(this.animate);this.renderer.render(this.scene, this.camera);this.controls.update();},exportModelData() {const modelData = {objects: this.scene.children.filter((obj) => obj instanceof THREE.Mesh) // 过滤出是 Mesh 对象的物体.map((obj) => ({position: obj.position.toArray(),scale: obj.scale.toArray(),rotation: obj.rotation.toArray(),color: `#${obj.material.color.getHexString()}`,})),};const jsonData = JSON.stringify(modelData);const blob = new Blob([jsonData], { type: "application/json" });const url = URL.createObjectURL(blob);const a = document.createElement("a");a.style.display = "none";a.href = url;a.download = "model_data.json";document.body.appendChild(a);a.click();URL.revokeObjectURL(url);document.body.removeChild(a);},importModelData(event) {const file = event.target.files[0];if (file) {const reader = new FileReader();reader.onload = () => {try {const data = JSON.parse(reader.result);console.log("Imported data:", data); // 输出导入的完整数据,确保格式和内容正确this.clearScene();data.objects.forEach((objData, index) => {const geometry = new THREE.BoxGeometry();// 设置默认颜色为红色const color = new THREE.Color(0xff0000); // 红色// 如果数据中有颜色字段并且是合法的颜色值,则使用数据中的颜色if (objData.color && typeof objData.color === "string") {try {color.set(objData.color);} catch (error) {console.error(`Error parsing color for object ${index}:`, error);}} else {console.warn(`Invalid color value for object ${index}:`, objData.color);}const material = new THREE.MeshStandardMaterial({color: color,metalness: 0.5, // 示例中的金属度设置为0.5,可以根据需求调整roughness: 0.8, // 示例中的粗糙度设置为0.8,可以根据需求调整});const object = new THREE.Mesh(geometry, material);object.position.fromArray(objData.position);object.scale.fromArray(objData.scale);object.rotation.fromArray(objData.rotation);this.scene.add(object);});} catch (error) {console.error("Error importing model data:", error);}};reader.readAsText(file);}},clearScene() {while (this.scene.children.length > 0) {this.scene.remove(this.scene.children[0]);}},saveModelData() {const modelData = {objects: this.scene.children.map((obj) => ({position: obj.position.toArray(),scale: obj.scale.toArray(),rotation: obj.rotation.toArray(),color: `#${obj.material.color.getHexString()}`,})),};localStorage.setItem("modelData", JSON.stringify(modelData));},loadModelData() {const savedData = localStorage.getItem("modelData");if (savedData) {try {const data = JSON.parse(savedData);this.clearScene();data.objects.forEach((objData) => {const geometry = new THREE.BoxGeometry();const material = new THREE.MeshStandardMaterial({color: parseInt(objData.color.replace("#", "0x"), 16),});const object = new THREE.Mesh(geometry, material);object.position.fromArray(objData.position);object.scale.fromArray(objData.scale);object.rotation.fromArray(objData.rotation);this.scene.add(object);});} catch (error) {console.error("Error loading model data from localStorage:", error);}}},},
};
</script><style>
body {margin: 0;overflow: hidden;
}canvas {display: block;
}#info {position: absolute;top: 10px;left: 10px;background: rgba(255, 255, 255, 0.8);padding: 10px;
}#editor {position: absolute;top: 100px;left: 10px;background: rgba(255, 255, 255, 0.8);padding: 10px;
}
</style>
二、2.0.0版本
1. 修复模型垂直方向放置时 模型会重合
4. 修复了导出导入功能 现在是1:1导出导入
5. 新增一个地面 视角看不到地下 设置了禁止编辑地面 地面设置为圆形
6. 新增功能 可选择基本圆形 方形 圆柱形等模型以及可放置自己的模型文件
7. 优化面板样式
<template><div id="app" @click="onAppClick"><div id="info"><button @click.stop="toggleBuildingMode">{{ buildingMode ? "关闭建造模式" : "开启建造模式" }}</button><button @click.stop="showEditor">编辑所选模型</button><button @click.stop="exportModelData">导出模型数据</button><input type="file" @change="importModelData" ref="fileInput" /><input type="file" @change="importCustomModel" ref="customModelInput" /><label for="modelType">模型类型:</label><select v-model="selectedModelType"><option value="box">立方体</option><option value="sphere">球体</option><option value="cylinder">圆柱体</option><option value="custom">自定义模型</option></select></div><div id="editor" v-if="editorVisible" @click.stop><h3>编辑模型</h3><div class="form-group"><label for="color">颜色:</label><input type="color" id="color" v-model="selectedObjectProps.color" /><br /></div><div class="form-group"><label for="posX">位置 X:</label><input type="number" id="posX" v-model="selectedObjectProps.posX" step="0.1" /><br /></div><div class="form-group"><label for="posY">位置 Y:</label><input type="number" id="posY" v-model="selectedObjectProps.posY" step="0.1" /><br /></div><div class="form-group"><label for="posZ">位置 Z:</label><input type="number" id="posZ" v-model="selectedObjectProps.posZ" step="0.1" /><br /></div><div class="form-group"><label for="scaleX">缩放 X:</label><input type="number" id="scaleX" v-model="selectedObjectProps.scaleX" step="0.1" /><br /></div><div class="form-group"><label for="scaleY">缩放 Y:</label><input type="number" id="scaleY" v-model="selectedObjectProps.scaleY" step="0.1" /><br /></div><div class="form-group"><label for="scaleZ">缩放 Z:</label><input type="number" id="scaleZ" v-model="selectedObjectProps.scaleZ" step="0.1" /><br /></div><div class="form-group"><label for="rotX">旋转 X:</label><input type="number" id="rotX" v-model="selectedObjectProps.rotX" step="0.1" /><br /></div><div class="form-group"><label for="rotY">旋转 Y:</label><input type="number" id="rotY" v-model="selectedObjectProps.rotY" step="0.1" /><br /></div><div class="form-group"><label for="rotZ">旋转 Z:</label><input type="number" id="rotZ" v-model="selectedObjectProps.rotZ" step="0.1" /><br /></div><button @click="applyEdit">应用</button><button @click="deleteBuilding">删除</button></div><div ref="canvasContainer" style="width: 100vw; height: 100vh"></div></div>
</template><script>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";export default {data() {return {editorVisible: false,selectedObject: null,selectedObjectProps: {color: "#000",posX: 0,posY: 0,posZ: 0,scaleX: 1,scaleY: 1,scaleZ: 1,rotX: 0,rotY: 0,rotZ: 0,},raycaster: null,buildingMode: false,selectedModelType: "box",customModel: null,};},mounted() {this.init();this.animate();window.addEventListener("resize", this.onWindowResize, false);},methods: {animate() {requestAnimationFrame(this.animate);this.renderer.render(this.scene, this.camera);this.controls.update();},init() {console.log("Initializing Three.js");this.scene = new THREE.Scene();this.scene.background = new THREE.Color('0xcccccc');this.camera = new THREE.PerspectiveCamera(60,window.innerWidth / window.innerHeight,0.1,1000);this.camera.position.set(0, 10, 20);this.renderer = new THREE.WebGLRenderer({ antialias: true });this.renderer.setSize(window.innerWidth, window.innerHeight);this.$refs.canvasContainer.appendChild(this.renderer.domElement);this.controls = new OrbitControls(this.camera, this.renderer.domElement);this.controls.minDistance = 10;this.controls.maxDistance = 50;this.controls.maxPolarAngle = Math.PI / 2;const planeGeometry = new THREE.CircleGeometry(100, 32);const planeMaterial = new THREE.MeshStandardMaterial({ color: 0x999999 });const plane = new THREE.Mesh(planeGeometry, planeMaterial);plane.rotation.x = -Math.PI / 2;plane.userData.isGround = true;this.scene.add(plane);const light = new THREE.DirectionalLight(0xffffff, 1);light.position.set(5, 10, 7.5);this.scene.add(light);this.raycaster = new THREE.Raycaster();},onWindowResize() {this.camera.aspect = window.innerWidth / window.innerHeight;this.camera.updateProjectionMatrix();this.renderer.setSize(window.innerWidth, window.innerHeight);},onAppClick(event) {const mouse = new THREE.Vector2();mouse.x = (event.clientX / window.innerWidth) * 2 - 1;mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;this.raycaster.setFromCamera(mouse, this.camera);const intersects = this.raycaster.intersectObjects(this.scene.children, true);if (this.buildingMode && intersects.length > 0) {const intersect = intersects[0];const point = intersect.point;if (intersect.object.userData.isGround) {if (this.isOverlapping(point.x, point.z)) {this.stackBuilding(point.x, point.z);} else {this.addBuilding(point.x, 0, point.z);}} else {const stackHeight = intersect.object.position.y + intersect.object.scale.y;this.addBuilding(intersect.object.position.x, stackHeight, intersect.object.position.z);}} else if (intersects.length > 0) {this.selectedObject = intersects[0].object;console.log("Object selected:", this.selectedObject);this.showEditor();}},isOverlapping(x, z) {const threshold = 1;for (let obj of this.scene.children) {if (Math.abs(obj.position.x - x) < threshold &&Math.abs(obj.position.z - z) < threshold &&!obj.userData.isGround) {return true;}}return false;},stackBuilding(x, z) {let maxY = 0;this.scene.children.forEach((obj) => {if (Math.abs(obj.position.x - x) < 1 &&Math.abs(obj.position.z - z) < 1 &&!obj.userData.isGround &&obj.position.y + obj.scale.y > maxY) {maxY = obj.position.y + obj.scale.y;}});this.addBuilding(x, maxY, z);},addBuilding(x, y, z) {let geometry;switch (this.selectedModelType) {case "sphere":geometry = new THREE.SphereGeometry(0.5, 32, 32);break;case "cylinder":geometry = new THREE.CylinderGeometry(0.5, 0.5, 1, 32);break;case "custom":if (this.customModel) {this.loadCustomModel(x, y, z);return;}break;case "box":default:geometry = new THREE.BoxGeometry(1, 1, 1);break;}if (geometry) {const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });const building = new THREE.Mesh(geometry, material);building.position.set(x, y, z);this.scene.add(building);}},loadCustomModel(x, y, z) {const loader = new GLTFLoader();loader.load(this.customModel,(gltf) => {const object = gltf.scene;object.position.set(x, y, z);this.scene.add(object);},undefined,(error) => {console.error("An error happened while loading the custom model", error);});},importCustomModel(event) {const file = event.target.files[0];this.customModel = URL.createObjectURL(file);},showEditor() {if (this.selectedObject) {this.selectedObjectProps.color = "#" + this.selectedObject.material.color.getHexString();this.selectedObjectProps.posX = this.selectedObject.position.x;this.selectedObjectProps.posY = this.selectedObject.position.y;this.selectedObjectProps.posZ = this.selectedObject.position.z;this.selectedObjectProps.scaleX = this.selectedObject.scale.x;this.selectedObjectProps.scaleY = this.selectedObject.scale.y;this.selectedObjectProps.scaleZ = this.selectedObject.scale.z;this.selectedObjectProps.rotX = this.selectedObject.rotation.x;this.selectedObjectProps.rotY = this.selectedObject.rotation.y;this.selectedObjectProps.rotZ = this.selectedObject.rotation.z;}this.editorVisible = true;},applyEdit() {if (this.selectedObject) {this.selectedObject.material.color.set(this.selectedObjectProps.color);this.selectedObject.position.set(this.selectedObjectProps.posX,this.selectedObjectProps.posY,this.selectedObjectProps.posZ);this.selectedObject.scale.set(this.selectedObjectProps.scaleX,this.selectedObjectProps.scaleY,this.selectedObjectProps.scaleZ);this.selectedObject.rotation.set(this.selectedObjectProps.rotX,this.selectedObjectProps.rotY,this.selectedObjectProps.rotZ);}this.editorVisible = false;},deleteBuilding() {if (this.selectedObject) {this.scene.remove(this.selectedObject);this.selectedObject.geometry.dispose();this.selectedObject.material.dispose();this.selectedObject = null;this.editorVisible = false;}},toggleBuildingMode() {this.buildingMode = !this.buildingMode;},exportModelData() {const modelData = this.scene.children.filter((obj) => obj.type === "Mesh" && !obj.userData.isGround).map((obj) => ({type: obj.geometry.type,position: obj.position,rotation: obj.rotation,scale: obj.scale,color: obj.material.color.getHex(),}));const blob = new Blob([JSON.stringify(modelData)], { type: "application/json" });const link = document.createElement("a");link.href = URL.createObjectURL(blob);link.download = "modelData.json";link.click();},importModelData(event) {const file = event.target.files[0];const reader = new FileReader();reader.onload = (e) => {const modelData = JSON.parse(e.target.result);this.loadModelData(modelData);};reader.readAsText(file);},loadModelData(modelData = null) {if (!modelData) {return;}modelData.forEach((data) => {let geometry;switch (data.type) {case "SphereGeometry":geometry = new THREE.SphereGeometry(0.5, 32, 32);break;case "CylinderGeometry":geometry = new THREE.CylinderGeometry(0.5, 0.5, 1, 32);break;case "BoxGeometry":default:geometry = new THREE.BoxGeometry(1, 1, 1);break;}const material = new THREE.MeshStandardMaterial({ color: data.color });const object = new THREE.Mesh(geometry, material);object.position.copy(data.position);object.rotation.copy(data.rotation);object.scale.copy(data.scale);this.scene.add(object);});},},
};
</script><style>
#app {font-family: Avenir, Helvetica, Arial, sans-serif;-webkit-font-smoothing: antialias;-moz-osx-font-smoothing: grayscale;text-align: center;color: #2c3e50;
}#info {position: absolute;top: 10px;left: 10px;background: rgba(255, 255, 255, 0.8);padding: 10px;border-radius: 5px;
}#editor {position: absolute;top: 50px;right: 10px;background: rgba(255, 255, 255, 0.9);padding: 10px;border-radius: 5px;z-index: 1000;width: 200px;
}#editor .form-group {margin-bottom: 10px;
}#editor label {display: block;margin-bottom: 5px;
}#editor input {width: 100%;
}
</style>
总结
未完待续