← Back to all guides

Object Kompositor

the Object Kompositor is the core class of an 3D composition tool

About

This handles core functionality of the komputer.space tool Object Kompositor. It is based on three.js and implements tool functionality like setup, updating, input handling, manipulating the three scene, and defining render options based on other modules.


Code

import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { ThreeExporter } from "./ThreeExporter";
import { FileImporter } from "./FileImporter";
import { GamePadInput } from "./GamePadInput";
import { SerialInput } from "./SerialInput";
import { InfoLayer } from "./InfoLayer";

export class ObjectCompositor {
  constructor(canvas) {
    this.transparencyMode = false;
    this.freeze = false;
    this.idle = false;

    this.exampleIndex = 0;
    this.examples = ["goethe", "macintosh", "stuhl", "mate"];

    this.exporter = new ThreeExporter();
    this.loader = new FileImporter(this);

    this.infoLayer = new InfoLayer();

    this.gltfLoader = new GLTFLoader();
    this.textureLoader = new THREE.TextureLoader();

    // -------

    this.canvas = canvas;

    this.currentFilter = 0;

    this.gamePadInput = new GamePadInput();
    this.serialInput = new SerialInput(115200);
    document.addEventListener("keydown", (e) => this.processKeyInput(e));

    this.setupScene();

    this.importGlTF("/examples/goethe.glb");
    this.applyMaterialFilter(3);
  }

  setupScene() {
    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );
    this.camera.position.z = 5;

    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas,
      preserveDrawingBuffer: true,
    });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setClearColor(0x000000, 0);

    // this.mainLight = new THREE.DirectionalLight(0xffffff, 5);
    this.mainLight = new THREE.AmbientLight(0xffffff);
    this.mainLight.position.z = 5;
    this.scene.add(this.mainLight);

    const ambientLight = new THREE.AmbientLight(0x000000);
    this.scene.add(ambientLight);

    const light1 = new THREE.DirectionalLight(0xffffff, 2);
    light1.position.set(0, 10, 0);
    this.scene.add(light1);

    const light2 = new THREE.DirectionalLight(0xffffff, 2);
    light2.position.set(5, 10, 7);
    this.scene.add(light2);

    this.orbitControls = new OrbitControls(
      this.camera,
      this.renderer.domElement
    );

    this.objects = [];
  }

  // --- CORE METHODS

  update() {
    if (!this.freeze) {
      if (this.objects.length > 0) {
        this.gamePadInput.update();
        this.applyGamepadInput();
        this.processSerialData();
      }
      this.orbitControls.update();
    }

    this.renderer.render(this.scene, this.camera);
  }

  resize(width, height) {
    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(width, height);
  }

  loadNewExample() {
    console.log("loading next example");
    this.exampleIndex++;
    if (this.exampleIndex >= this.examples.length) this.exampleIndex = 0;
    const fileName = this.examples[this.exampleIndex];
    this.importGlTF("/examples/" + fileName + ".glb", true);
  }

  setViewMode(value) {
    this.freeze = value;
  }

  setTransparencyMode(value) {
    this.transparencyMode = value;
    this.setWireframe(value);
  }

  setIdleMode(value) {
    this.idle = value;
    this.orbitControls.autoRotate = value;
  }

  // --- INPUTS

  applyGamepadInput() {
    const activeObject = this.objects[this.objects.length - 1];

    if (this.gamePadInput.rotationMode && !this.serialInput.connected) {
      activeObject.rotation.x +=
        ((5 * Math.PI) / 360) * this.gamePadInput.control.x;
      activeObject.rotation.z -=
        ((5 * Math.PI) / 360) * this.gamePadInput.control.z;
      activeObject.rotation.y +=
        ((5 * Math.PI) / 360) * this.gamePadInput.control.y;
    } else {
      activeObject.position.x += 0.05 * this.gamePadInput.control.x;
      activeObject.position.z += 0.05 * this.gamePadInput.control.z;
      activeObject.position.y -= 0.05 * this.gamePadInput.control.y;
    }
  }

  processSerialData() {
    if (this.serialInput.connected && this.serialInput.serialData) {
      const input = this.serialInput.serialData.slice(1, -1);
      const splitted = input.split(".");
      let data = [];
      data[0] = ((splitted[0] << 8) | splitted[1]) / 16384.0;
      data[1] = ((splitted[2] << 8) | splitted[3]) / 16384.0;
      data[2] = ((splitted[4] << 8) | splitted[5]) / 16384.0;
      data[3] = ((splitted[6] << 8) | splitted[7]) / 16384.0;
      for (let i = 0; i < 4; i++) if (data[i] >= 2) data[i] = -4 + data[i];
      const q = data.slice(0, 4);
      const qTransformed = [q[1], q[3], -q[2], q[0]];

      const quat = new THREE.Quaternion();
      quat.fromArray(qTransformed);
      quat.normalize();

      const activeObject = this.objects[this.objects.length - 1];
      activeObject.setRotationFromQuaternion(quat);
    }
  }

  processKeyInput(e) {
    if (e.code.includes("Digit")) {
      this.applyMaterialFilter(parseInt(e.code.slice(-1)));
    } else if (e.code == "KeyX") {
      this.resetScene();
    } else if (e.code == "Tab") {
    }
  }

  // --- CUSTOM METHODS

  setWireframe(value) {
    this.objects.forEach((obj) => {
      obj.traverse((element) => {
        if (element.material) {
          element.material.wireframe = value;
        }
      });
    });
  }

  updateObjects() {
    this.setWireframe(this.transparencyMode);
    this.applyMaterialFilter(this.currentFilter);
  }

  replaceObject(newObject) {
    console.log("replace");
    console.log(newObject);
    const oldObject = this.objects[this.objects.length - 1];
    this.scene.remove(oldObject);
    this.objects.pop();
    this.objects.push(newObject);
    if (oldObject) newObject.applyMatrix4(oldObject.matrix);
    this.scene.add(newObject);
    this.updateObjects();
  }

  addObject(newObject) {
    console.log("add");
    console.log(newObject);
    this.objects.push(newObject);
    this.scene.add(newObject);
    this.updateObjects();
  }

  adaptObjectToScene(object) {
    console.log("adapt");
    const box = new THREE.Box3().setFromObject(object);
    const offsetPosition = box.getCenter(new THREE.Vector3());
    const scaleFactor = 2.5 / box.getSize(new THREE.Vector3()).y;
    console.log(scaleFactor);
    const wrapperObject = new THREE.Group();
    wrapperObject.add(object);
    const adaptedPosition = object.position.clone().sub(offsetPosition);
    object.position.copy(adaptedPosition.multiplyScalar(scaleFactor));
    object.scale.multiplyScalar(scaleFactor);
    wrapperObject.traverse((obj) => {
      obj.receiveShadow = false;
      if (obj.isMesh) obj.geometry.computeVertexNormals();
    });

    console.log(wrapperObject);
    return wrapperObject;
  }

  resetScene() {
    console.log("reset");
    this.objects.forEach((object) => {
      this.scene.remove(object);
    });
    this.objects = [];
  }

  applyTexture(texture) {
    this.objects.forEach((obj) => {
      obj.traverse((element) => {
        if (element.material) {
          element.material.map = texture;
          console.log("update texture");
          console.log(element.material);
        }
      });
    });
  }

  applyMaterial(newMaterial) {
    this.objects.forEach((obj) => {
      obj.traverse((element) => {
        if (element.material) {
          element.material = newMaterial;
        }
      });
    });
  }

  applyMaterialFilter(index) {
    switch (index) {
      case 1:
        const silhouMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
        this.applyMaterial(silhouMaterial);
        break;
      case 2:
        const normalMaterial = new THREE.MeshNormalMaterial();
        this.applyMaterial(normalMaterial);
        break;
      case 3:
        const matcap = new THREE.MeshMatcapMaterial();
        this.applyMaterial(matcap);
        break;
      case 4:
        const toon = new THREE.MeshToonMaterial({ color: 0xff4500 });
        this.applyMaterial(toon);
        break;
      case 5:
        const shiny = new THREE.MeshPhysicalMaterial({
          color: 0xffffff,
          emissive: 0x000000,
          roughness: 0.3,
          metalness: 1.1,
        });
        this.applyMaterial(shiny);
        break;
      case 6:
        const shiny2 = new THREE.MeshStandardMaterial({
          color: 0x00ffd5,
          roughness: 0.3,
          metalness: 0.8,
        });
        this.applyMaterial(shiny2);
        break;
      default:
        break;
    }
    this.currentFilter = index;
    this.setWireframe(this.transparencyMode);
  }

  // --- FILE EXPORTS

  exportScene() {
    this.exporter.exportGlTF(this.scene);
  }

  // --- FILE IMPORTS

  importGlTF(url, replace = false) {
    this.infoLayer.setActive(true);
    this.gltfLoader.load(
      url,
      (gltf) => {
        let importedObject = gltf.scene;
        importedObject = this.adaptObjectToScene(importedObject);
        if (replace) {
          this.replaceObject(importedObject);
        } else {
          this.addObject(importedObject);
        }
        this.infoLayer.setActive(false);
      },
      function (xhr) {
        this.infoLayer.showLoadingIndicator(
          Math.round((xhr.loaded / xhr.total) * 100)
        );
      }.bind(this),
      function (error) {
        console.log("could not load object");
        console.error(error);
        reject();
      }
    );
  }

  importImage(url) {
    this.infoLayer.setActive(true);
    this.infoLayer.showInfo("processing …");
    this.textureLoader.load(
      url,
      (texture) => {
        this.applyTexture(texture);
        this.infoLayer.setActive(false);
      },
      undefined,
      function (error) {
        console.log("could not load texture");
        console.error(error);
        reject();
      }
    );
  }
}