文章目录
- 序
- 一、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>
      <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>
总结
未完待续



















