Source: modules/VoxelsEditor.js

// Import Three.js
import * as THREE from "three";

// Import orbit controls
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// Import Exporters
import { OBJExporter } from "three/examples/jsm/exporters/OBJExporter";
import { PLYExporter } from "three/examples/jsm/exporters/PLYExporter";
import { ColladaExporter } from "three/examples/jsm/exporters/ColladaExporter";
import { STLExporter } from "three/examples/jsm/exporters/STLExporter";

// Import modules
import VoxelWorld from "./VoxelWorld";
import Brush from "./Brush";
import ColorPalette from "./ColorPalette";

// Import FileSaver
import FileSaver from "file-saver";

// Import image assets
//import textureAtlas from "../images/flourish-cc-by-nc-sa.png";

/**
 * Has the cell at the given coordinates form a flat ground out of its voxels.
 * @param {VoxelWorld} world - The world to spawn flat ground in
 * @param {number} cellX
 * @param {number} cellY
 * @param {number} cellZ
 * @param {number} cellSize - Dimensions of the cell
 * @param {number} [v=1] - The type of voxel to spawn.
 */
function createFlatGround(world, cellX, cellY, cellZ, cellSize, v = 1) {
  const startX = cellX * cellSize;
  const startY = cellY * cellSize;
  const startZ = cellZ * cellSize;

  // Create flat ground with our voxels
  for (let z = 0; z < cellSize; ++z) {
    for (let x = 0; x < cellSize; ++x) {
      world.setVoxel(startX + x, startY, startZ + z, v);
    }
  }
}

/*
 * TODO: Temporary function for creating the texture atlas. Will be removed
 * during the creation of the ColorPalette code.
 * @param {*} render
 * @return texture
 */
/*
function createTextureAtlas(render) {
  // Load texture atlas
  const loader = new THREE.TextureLoader();
  const texture = loader.load(textureAtlas, render);
  texture.magFilter = THREE.NearestFilter;
  texture.minFilter = THREE.NearestFilter;
  return texture;
}
*/

/**
 * Class used to interface with the scene and handles the main render loop.
 */
class VoxelEditor {
  constructor(options) {
    this.canvas = options.canvas;
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas,
      antialias: true,
    });

    // Length, width, and height of each cell in the VoxelWorld
    this.cellSize = 32;

    // Initialize the camera
    this.createCamera();

    // Initialize orbit controls
    this.createOrbitControls();

    // Create the scene
    this.scene = new THREE.Scene();

    // Setting background color to the same one Blender uses
    this.scene.background = new THREE.Color("#3C3C3C");

    // Add two directional lights to the scene
    this.addLight(-1, 2, 4);
    this.addLight(1, -1, -2);

    // TODO: Remove these variables soon. Not needed for ColorPalette
    const tileSize = 16;
    const tileTextureWidth = 256;
    const tileTextureHeight = 64;
    //const texture = createTextureAtlas(this.render);

    // Create material for the voxel model
    const material = new THREE.MeshLambertMaterial({
      // TODO: add texture back if using textures
      //map: texture,
      side: THREE.DoubleSide,
      alphaTest: 0.1,
      transparent: true,
      vertexColors: true,
    });

    // Load from previous world or set defaults
    const { world } = options;
    const colorPalette = world ? world.colorPalette : new ColorPalette();
    const cells = world ? world.cells : {};

    // Create a new VoxelWorld that will manage our voxels
    this.world = new VoxelWorld({
      cellSize: this.cellSize,
      tileSize,
      tileTextureWidth,
      tileTextureHeight,
      material,
      colorPalette,
      cells,
    });

    // If there is no pre-existing world, create flat ground by default
    if (!world) {
      // Create a floor to the world
      createFlatGround(this.world, 0, 0, 0, this.cellSize, 1); // Center
    }

    // Update geometry of the entire world
    this.world.updateWorldGeometry(this.scene);

    // Used with requestRenderIfNotRequested() function
    this.renderRequested = false;

    // Mouse object representing the position of mouse clicks.
    this.mouse = {
      x: 0,
      y: 0,
      moveX: 0,
      moveY: 0,
    };

    // Listen for mouse clicks
    this.canvas.addEventListener(
      "pointerdown",
      (event) => {
        event.preventDefault();
        // Record where we first clicked
        this.recordStartPosition(event);

        // Record mouse movement
        window.addEventListener("pointermove", this.recordMovement);

        // Add voxel upon releasing mouse click if movement is small. Other,
        // user is orbiting the camera
        window.addEventListener("pointerup", this.placeVoxelIfNoMovement);
      },
      { passive: false }
    );

    // Listen for touch events
    this.canvas.addEventListener(
      "touchstart",
      (event) => {
        // prevent scrolling
        event.preventDefault();
      },
      { passive: false }
    );

    // Listen for camera orbit events
    this.controls.addEventListener("change", this.requestRenderIfNotRequested);

    // Listen for window resizing events
    window.addEventListener("resize", this.requestRenderIfNotRequested);

    // Create new brush
    this.brush = new Brush();

    // Start render loop
    this.render();
  }

  /**
   * Helper function used to create the camera and set it to a default position.
   * @param {number} [fov=75] - field of view
   * @param {number} [aspect=2] - Aspect. Canvas default is 2
   * @param {number} [near=0.1]
   * @param {number} [far=1000]
   */
  createCamera(fov = 75, aspect = 2, near = 0.1, far = 1000) {
    // Create a new perspective camera
    this.camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

    // TODO: This is an arbitrary starting position. Consider an alternative
    this.camera.position.set(
      -this.cellSize * 0.2,
      this.cellSize * 0.3,
      -this.cellSize * 0.2
    );
  }

  /**
   * Helper function to create the orbit controls.
   */
  createOrbitControls() {
    // Create the orbit controls
    this.controls = new OrbitControls(this.camera, this.canvas);

    // Orbit controls starts by targeting center of scene
    this.controls.target.set(this.cellSize / 2, 0, this.cellSize / 2);

    // Controls must be updated before they can be used
    this.controls.update();
  }

  /**
   * Adds a directional light to the scene at the given x, y, and z position.
   * Remember, the default position of the directional light's target is (0, 0, 0).
   * @param {number} x
   * @param {number} y
   * @param {number} z
   */
  addLight(x, y, z) {
    const color = 0xffffff;
    const intensity = 1;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(x, y, z);
    this.scene.add(light);
  }

  /**
   * Checks if the renderer needs to resize to account for changes in screen
   * width or height.
   * @param {WebGLRenderer} renderer
   * @returns {boolean} True if the renderer resized. False otherwise.
   */
  resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;

    const width = canvas.clientWidth;
    const height = canvas.clientHeight;

    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }
    return needResize;
  }

  /**
   * Main render loop.
   * @function
   */
  render = () => {
    this.renderRequested = undefined;

    if (this.resizeRendererToDisplaySize(this.renderer)) {
      this.camera.aspect = this.canvas.clientWidth / this.canvas.clientHeight;
      this.camera.updateProjectionMatrix();
    }

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

  /**
   * Used to make a render update request only if one hasn't been made already.
   * @function
   */
  requestRenderIfNotRequested = () => {
    if (!this.renderRequested) {
      this.renderRequested = true;
      requestAnimationFrame(this.render);
    }
  };

  /**
   * Finds the x and y coordinate of a mouse click relative to the canvas.
   * @param {Event} event
   * @returns {Object} Object with x and y coordinates of click relative to canvas
   */
  getCanvasRelativePosition(event) {
    const { canvas } = this;
    const rect = canvas.getBoundingClientRect();

    // Calculate the x and y of click relative to the canvas
    return {
      x: ((event.clientX - rect.left) * canvas.width) / rect.width,
      y: ((event.clientY - rect.top) * canvas.height) / rect.height,
    };
  }

  /**
   * Handler for adding, removing, or painting a voxel based on the given brush
   * and where the user clicked.
   * @param {Event} event
   */
  placeVoxel(event) {
    // Find position of mouse click relative to canvas
    const pos = this.getCanvasRelativePosition(event);
    const x = (pos.x / this.canvas.width) * 2 - 1;
    const y = (pos.y / this.canvas.height) * -2 + 1; // note we flip Y

    // Get the starting and ending vectors for our raycast
    const start = new THREE.Vector3();
    const end = new THREE.Vector3();
    start.setFromMatrixPosition(this.camera.matrixWorld);
    end.set(x, y, 1).unproject(this.camera);

    // Cast a ray into the scene
    const intersection = this.world.intersectRay(start, end);

    // If raycast wasn't successful, return
    if (!intersection) return;

    // Add voxels depending on the current brush type
    switch (this.brush.currentType) {
      // Single type brush
      case Brush.brushTypes.single:
        this.singleBrushAction(intersection);
        break;

      // Extrude type brush
      case Brush.brushTypes.extrude:
        this.extrudeBrushAction(intersection);
        break;

      default:
      // No default case
    }
  }

  /**
   * Perform the current brush action for the single brush type.
   * For add, add a single voxel.
   * For remove, remove a single voxel.
   * For paint, paint a single voxel.
   * @param {Object} intersection
   */
  singleBrushAction = (intersection) => {
    // Set voxelId depending on brush option. 0 removes voxels
    const voxelId =
      this.brush.currentAction === Brush.brushActions.remove
        ? 0
        : this.world.colorPalette.getSelectedColorIndex() + 1;

    // the intersection point is on the face. That means
    // the math imprecision could put us on either side of the face.
    // so go half a normal into the voxel if removing/painting
    // or half a normal out if adding
    const pos = intersection.position.map((v, ndx) => {
      return (
        v +
        intersection.normal[ndx] *
          (this.brush.currentAction === Brush.brushActions.add ? 0.5 : -0.5)
      );
    });

    // Set voxel at the pos position with new voxelID
    this.world.setVoxel(...pos, voxelId);

    // Update the cell associated with the position of the new voxel
    this.world.updateVoxelGeometry(this.scene, ...pos);

    // Update render frame
    this.requestRenderIfNotRequested();
  };

  /**
   * Perform the current brush action for the extrude brush type.
   * For add, place a layer of voxels on all adjacent voxels of the same color along
   * the clicked side.
   * For remove, remove a layer of adjacent voxels of the same color along the clicked side.
   * For paint, paint a layer of adjacent voxels of the same color along the clicked side.
   * @param {Object} intersection
   */
  extrudeBrushAction = (intersection) => {
    // Set voxelId depending on brush option. 0 removes voxels
    const voxelId =
      this.brush.currentAction === Brush.brushActions.remove
        ? 0
        : this.world.colorPalette.getSelectedColorIndex() + 1;

    // Get position of voxel that was intersected
    const pos = intersection.position.map((v, ndx) => {
      // Return the position of the voxel that was clicked
      return v + intersection.normal[ndx] * -0.5;
    });

    // True if adding more geometry
    const isExtruding = this.brush.currentAction === Brush.brushActions.add;

    // Extrude the voxels or just re-paint voxels of the same type if not extruding
    this.world.floodFillVoxels(
      ...pos,
      ...intersection.normal,
      voxelId,
      isExtruding
    );

    // Update world geometry
    // @TODO: Find a way to only update cells that need an update instead of everything
    // As an idea, maybe world should have an "out of date" variable for cells?
    this.world.updateWorldGeometry(this.scene);

    // Update render frame
    this.requestRenderIfNotRequested();
  };

  /**
   * Reset mouse movement and begin recording.
   * @function
   * @param {Event} event
   */
  recordStartPosition = (event) => {
    const { mouse } = this;
    mouse.x = event.clientX;
    mouse.y = event.clientY;
    mouse.moveX = 0;
    mouse.moveY = 0;
  };

  /**
   * Callback function used to record how far the mouse has moved since started recording.
   * @function
   * @param {Event} event
   */
  recordMovement = (event) => {
    const { mouse } = this;
    mouse.moveX += Math.abs(mouse.x - event.clientX);
    mouse.moveY += Math.abs(mouse.y - event.clientY);
  };

  /**
   * Callback function used to check if the user meant to set a voxel instead
   * of orbiting the camera.
   * @function
   * @param {Event} event
   */
  placeVoxelIfNoMovement = (event) => {
    const { mouse } = this;
    // Mouse hardly moved, user likely intended to place a voxel
    if (mouse.moveX < 5 && mouse.moveY < 5) {
      this.placeVoxel(event);
    }

    // Stop recording movement and checks to place voxel
    window.removeEventListener("pointermove", this.recordMovement);
    window.removeEventListener("pointerup", this.placeVoxelIfNoMovement);
  };

  /**
   * Called whenever a new color is selected.
   * @function
   * @param {number} index - Index of the changed color
   * @param {r} r - Red color from 0-1
   * @param {g} g - Green color from 0-1
   * @param {b} b - Blue color from 0-1
   */
  onSelectedColorChange = (index, r, g, b) => {
    // Update the color
    this.world.colorPalette.setColorAtIndex(index, r, g, b);

    // Updated the world with new color
    this.world.updateWorldGeometry(this.scene);

    // Update render frame
    this.requestRenderIfNotRequested();
  };

  /**
   * Updates which voxel the user is placing/painting now from the palette.
   * @function
   * @param {number} index
   */
  onNewSelectedColor = (index) => {
    // Update the currently selected color for adding/painting
    this.world.colorPalette.setSelectedColor(index);
  };

  /**
   * Gets project data from the currently open project.
   * @function
   * @returns {Object} JavaScript object representing the relevant data from the
   * currently open project/scene.
   */
  onGetProjectData = () => {
    const projectObj = {
      voxelWorld: {
        cellSize: this.world.cellSize,
        cells: this.world.cells,
      },
      colorPalette: {
        colors: this.world.colorPalette.getColorsArray(),
        selectedColor: this.world.colorPalette.getSelectedColorIndex(),
      },
    };

    return projectObj;
  };

  /**
   * Loads a project from the given data.
   * @function
   * @param {Object} projectData
   */
  onLoadProjectData = (projectData) => {
    const { voxelWorld, colorPalette } = projectData;

    // Remove the old cells from the world
    this.world.removeAllCells(this.scene);

    // Load data for the color palette
    this.world.colorPalette.setNewColorsArray(colorPalette.colors);
    this.world.colorPalette.setSelectedColor(colorPalette.selectedColor);

    // Load data for the VoxelWorld
    this.world.cells = voxelWorld.cells;
    this.world.cellSize = voxelWorld.cellSize;

    // Update world geometry and rerender
    this.world.updateWorldGeometry(this.scene);
    this.requestRenderIfNotRequested();
  };

  /**
   * Exports the current frame of the canvas to an image file.
   * @function
   * @param {string} imageName
   */
  onExportImage = (imageName) => {
    // Render must first be invoked to get current frame
    this.render();

    // Save the current frame as an image
    this.canvas.toBlob((blob) => {
      FileSaver.saveAs(blob, imageName + ".png");
    }, "image/png");
  };

  /**
   * Exports the voxel model to some 3D file format
   * @function
   * @param {string} name - What the exported file should be called
   * @param {string} type - The type of file to export
   */
  onExportModel = (name, type) => {
    // Create an exporter that matches the given type
    let exporter, blobType;
    switch (type) {
      case "obj":
        exporter = new OBJExporter();
        blobType = "model/obj";
        break;

      case "ply":
        exporter = new PLYExporter();
        // ply doesn't appear to have an official internet media type
        blobType = "text/plain";
        break;

      case "stl":
        exporter = new STLExporter();
        blobType = "model/stl";
        break;

      case "dae":
        exporter = new ColladaExporter();
        blobType = "model/vnd.collada+xml";
        break;

      default:
        exporter = null;
        break;
    }

    // If type is invalid, return
    if (!exporter) return;

    // Parse the scene for object data
    const result = exporter.parse(this.scene);

    // Create a blob to download with object data
    const blob = new Blob([result], {
      type: blobType,
    });

    // Save object file to user's device
    FileSaver.saveAs(blob, name + "." + type);
  };

  /**
   * Creates a new, empty voxel world
   * @function
   */
  onNewProject = () => {
    this.world.removeAllCells(this.scene);
    this.world.colorPalette.restoreDefaults();
    createFlatGround(this.world, 0, 0, 0, this.cellSize, 1); // Center

    // Update geometry of the entire world
    this.world.updateWorldGeometry(this.scene);

    this.requestRenderIfNotRequested();
  };
}

export default VoxelEditor;