Bootstrap

vue vite+three在线编辑模型导入导出

要实现一个类似于数字孪生的场景 可以在线、新增、删除模型 、以及编辑模型的颜色、长宽高
然后还要实现 编辑完后 保存为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>

总结

未完待续

;