文章目录
序
要实现一个类似于数字孪生的场景 可以在线、新增、删除模型 、以及编辑模型的颜色、长宽高
然后还要实现 编辑完后 保存为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>
<input
type="number"
id="posX"
v-model="selectedObjectProps.posX"
step="0.1"
/><br />
<label for="posY">Position Y:</label>
<input
type="number"
id="posY"
v-model="selectedObjectProps.posY"
step="0.1"
/><br />
<label for="posZ">Position Z:</label>
<input
type="number"
id="posZ"
v-model="selectedObjectProps.posZ"
step="0.1"
/><br />
<label for="scaleX">Scale X:</label>
<input
type="number"
id="scaleX"
v-model="selectedObjectProps.scaleX"
step="0.1"
/><br />
<label for="scaleY">Scale Y:</label>
<input
type="number"
id="scaleY"
v-model="selectedObjectProps.scaleY"
step="0.1"
/><br />
<label for="scaleZ">Scale Z:</label>
<input
type="number"
id="scaleZ"
v-model="selectedObjectProps.scaleZ"
step="0.1"
/><br />
<label for="rotX">Rotation X:</label>
<input
type="number"
id="rotX"
v-model="selectedObjectProps.rotX"
step="0.1"
/><br />
<label for="rotY">Rotation Y:</label>
<input
type="number"
id="rotY"
v-model="selectedObjectProps.rotY"
step="0.1"
/><br />
<label for="rotZ">Rotation Z:</label>
<input
type="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>
总结
未完待续