Legonizer.js

import {
  Vector2Input,
  Vector3Input,
  Vector4Input,
  createLabelInput,
  checkParentChild,
} from '@ud-viz/utils_browser';
import { PlanarView, MAIN_LOOP_EVENTS } from 'itowns';
import {
  BoxGeometry,
  Mesh,
  MeshLambertMaterial,
  Object3DEventMap,
  Vector2,
  Vector3,
  Vector4,
  MeshBasicMaterial,
} from 'three';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { InputManager } from '@ud-viz/game_browser';
import { createMockUpObject } from './MockUpUtils';
import { LegoMockupVisualizer } from './LegoMockupVisualizer';
import {
  createHeightMapFromBufferGeometry,
  generateCSVwithHeightMap,
} from 'legonizer';

/**
 * Provides functionality for generating a Lego mockup based on user-defined coordinates and scales.
 */
export class Legonizer {
  /**
   * Init properties and sets up the DOM elements and scene for a planar view.
   *
   * @param {PlanarView} view Represents the 3D view or scene. Objects will be displayed and manipulated.
   * @param {{parentDomElement:HTMLElement,domMockUpVisualizer:HTMLElement}} [options] Optionals parameter. Represents the user interface element. If no `parentDomElement` parameter is provided, the
   * `domElement` of widget is used. If no `domMockUpVisualizer` a default one is created
   */
  constructor(view, options = {}) {
    /** @type {HTMLElement} */
    this.domElement = null;
    /** @type {HTMLElement} */
    this.domMockUpVisualizer = options.domMockUpVisualizer || null;
    /** @type {HTMLElement} */
    this.parentDomElement = options.parentDomElement || this.domElement;
    /** @type {Vector3Input} */
    this.positionVec3Input = null;
    /** @type {Vector3Input} */
    this.rotationVec3Input = null;
    /** @type {Vector3Input} */
    this.scaleVec3Input = null;
    /** @type {Vector2Input} */
    this.countLegoVec2Input = null;
    /** @type {{parent:HTMLDivElement,input:HTMLInputElement,label:HTMLLabelElement}} */
    this.ratioParameterLabelInput = null;
    /** @type {HTMLButtonElement} */
    this.buttonSelectionAreaElement = null;
    /** @type {LegoMockupVisualizer} */
    this.legoMockupVisualizer = null;

    /** @type {PlanarView} */
    this.view = view;

    /** @type {Mesh<BoxGeometry, MeshLambertMaterial, Object3DEventMap>} */
    this.boxSelector = null;
    /** @type {Mesh<BoxGeometry, MeshLambertMaterial, Object3DEventMap>} */
    this.legoPrevisualisation = null;
    /** @type {TransformControls} */
    this.transformCtrls = null;

    /** @type {number} */
    this.ratio = 3;

    this.inputManager = new InputManager();

    this.initDomElement();
    this.initScene();
    this.view.addFrameRequester(MAIN_LOOP_EVENTS.AFTER_CAMERA_UPDATE, () => {
      this._updateFieldsFromBoxSelector();
    });
  }

  /**
   * Creates, set `domElement` and returns a DOM element.
   *
   * @returns {HTMLDivElement} `legonizerDomElement` the newly `<div>` element.
   */
  initDomElement() {
    const legonizerDomElement = document.createElement('div');
    legonizerDomElement.appendChild(this.createCoordinatesDomEl());
    legonizerDomElement.appendChild(this.createScaleDomEl());
    // Button Generate Lego Mockup
    const buttonGenerateMockupElement = document.createElement('button');
    buttonGenerateMockupElement.textContent = 'Generate Lego Mockup';
    buttonGenerateMockupElement.onclick = () => {
      this.generateMockup();
    };
    legonizerDomElement.appendChild(buttonGenerateMockupElement);

    this.domElement = legonizerDomElement;

    if (!this.domMockUpVisualizer) {
      const domMockUpVisualizer = document.createElement('div');
      domMockUpVisualizer.style.position = 'relative';
      domMockUpVisualizer.style.width = '100%';
      domMockUpVisualizer.style.aspectRatio = '16/9';
      domMockUpVisualizer.style.cursor = 'pointer';
      this.domMockUpVisualizer = domMockUpVisualizer;
    }
    this.domElement.appendChild(this.domMockUpVisualizer);
    return legonizerDomElement;
  }

  /**
   * Init the scene by creating a box selector, a Lego previsualization, and adding
   * transform controls to the scene.
   */
  initScene() {
    this.createBoxSelector();
    this.createLegoPrevisualisation();

    // Transform controls
    this.transformCtrls = new TransformControls(
      this.view.camera.camera3D,
      this.view.mainLoop.gfxEngine.label2dRenderer.domElement
    );

    // Update view when the box selector is changed
    this.transformCtrls.addEventListener('dragging-changed', (event) => {
      this.view.controls.enabled = !event.value;
    });
    this.transformCtrls.addEventListener('change', () => {
      this.transformCtrls.updateMatrixWorld();
      this.view.notifyChange();
    });
    this.view.scene.add(this.transformCtrls);
    this.view.scene.add(this.boxSelector);
    this.view.scene.add(this.legoPrevisualisation);
  }

  /**
   * Creates the DOMElement related to the coordinates.
   *
   * @returns {HTMLDivElement} DOM element that contains a set of input fields for position, rotation, and scale, as well as a button for selecting an area.
   */
  createCoordinatesDomEl() {
    // Coordinates Box DOM
    const coordinatesDomElement = document.createElement('div');

    const coordinatesTitle = document.createElement('h3');
    coordinatesTitle.innerText = 'Coordinates';
    coordinatesDomElement.appendChild(coordinatesTitle);

    this.positionVec3Input = new Vector3Input('Position', 1, 0);
    this.positionVec3Input.inputElements.forEach((input) => {
      input.addEventListener('change', (event) => {
        const value = event.target.value;
        if (value) {
          this.boxSelector.position.set(
            parseFloat(this.positionVec3Input.x.input.value),
            parseFloat(this.positionVec3Input.y.input.value),
            parseFloat(this.positionVec3Input.z.input.value)
          );
          this.boxSelector.updateMatrixWorld();
          this.transformCtrls.updateMatrixWorld();
          this.view.notifyChange();
        }
      });
    });
    coordinatesDomElement.appendChild(this.positionVec3Input);

    this.rotationVec3Input = new Vector3Input('Rotation', 1, 0);
    this.rotationVec3Input.inputElements.forEach((input) => {
      input.addEventListener('change', (event) => {
        const value = event.target.value;
        if (value) {
          this.boxSelector.rotation.set(
            parseFloat(this.rotationVec3Input.x.input.value),
            parseFloat(this.rotationVec3Input.y.input.value),
            parseFloat(this.rotationVec3Input.z.input.value)
          );
          this.boxSelector.updateMatrixWorld();
          this.transformCtrls.updateMatrixWorld();
          this.view.notifyChange();
        }
      });
    });
    coordinatesDomElement.appendChild(this.rotationVec3Input);

    this.scaleVec3Input = new Vector3Input('Scale', 1, 0);
    this.scaleVec3Input.inputElements.forEach((input) => {
      input.addEventListener('change', (event) => {
        const value = event.target.value;
        if (value) {
          this.boxSelector.scale.set(
            parseFloat(this.scaleVec3Input.x.input.value),
            parseFloat(this.scaleVec3Input.y.input.value),
            parseFloat(this.scaleVec3Input.z.input.value)
          );
          this.boxSelector.updateMatrixWorld();
          this.transformCtrls.updateMatrixWorld();
          this.view.notifyChange();
        }
      });
    });
    coordinatesDomElement.appendChild(this.scaleVec3Input);

    // Button Select an area
    this.buttonSelectionAreaElement = document.createElement('button');
    this.buttonSelectionAreaElement.textContent = 'Select an area';
    this.buttonSelectionAreaElement.onclick = () => {
      this.selectArea();
    };

    coordinatesDomElement.appendChild(this.buttonSelectionAreaElement);

    return coordinatesDomElement;
  }

  /**
   * Creates the DOMElement related to the scale.
   *
   * @returns {HTMLDivElement} DOM element of Scale Section. **Children:**
   *- **ratio**: This input number controls the accuracy of the heightmap.
   *- **countLego**: This vec2Input parameter specifies the number of plates to be used in the mockup.
   */
  createScaleDomEl() {
    // Scale Box DOM
    const scalesSectionDomElement = document.createElement('div');

    const scaleTitle = document.createElement('h3');
    scaleTitle.innerText = 'Scales Parameters';
    scalesSectionDomElement.appendChild(scaleTitle);

    this.ratioParameterLabelInput = createLabelInput('Ratio', 'number');
    this.ratioParameterLabelInput.input.value = 0;
    this.ratioParameterLabelInput.input.addEventListener('change', (event) => {
      const value = event.target.value;
      if (value) {
        this.ratio = this.ratioParameterLabelInput.input.value;
        this.boxSelector.updateMatrixWorld();
        this.transformCtrls.updateMatrixWorld();
        this.view.notifyChange();
      }
    });

    scalesSectionDomElement.appendChild(this.ratioParameterLabelInput.parent);

    this.countLegoVec2Input = new Vector2Input('Count Lego', 1, 0);

    scalesSectionDomElement.appendChild(this.countLegoVec2Input);
    return scalesSectionDomElement;
  }

  /**
   * Creates a box selector mesh to be used for selecting tiles.
   *
   * @returns {Mesh} The box selector mesh.
   */
  createBoxSelector() {
    // create a unit cube geometry.
    const geometry = new BoxGeometry(1, 1, 1);

    const boxSelector = new Mesh(
      geometry,
      new MeshLambertMaterial({
        color: 0x00ff00,
        opacity: 0.3,
        transparent: true,
      })
    );

    // position the box selector at the center of the tile layer.
    boxSelector.position.x = this.view.tileLayer.extent.center().x;
    boxSelector.position.y = this.view.tileLayer.extent.center().y;
    boxSelector.position.z = 200;

    boxSelector.updateMatrixWorld();

    this.boxSelector = boxSelector;
    return boxSelector;
  }

  /**
   * Creates a Lego previsualization mesh to be used for visualizing the selected area.
   *
   * @returns {Mesh} The Lego previsualization mesh.
   */
  createLegoPrevisualisation() {
    // calculate the dimensions of the Lego previsualization based on the ratio.
    const geometryLego = new BoxGeometry(
      this.ratio,
      this.ratio,
      (this.ratio * 9.6) / 7.8 // Lego dimension
    );

    const objectLego = new Mesh(
      geometryLego,
      new MeshLambertMaterial({
        color: 0x00ff00,
      })
    );

    // position the Lego previsualization at the same position as the box selector.
    objectLego.position.x = this.boxSelector.position.x;
    objectLego.position.y = this.boxSelector.position.y;
    objectLego.position.z = 300;

    objectLego.updateMatrixWorld();

    this.legoPrevisualisation = objectLego;
    return objectLego;
  }

  /**
   * Updates the form fields from the box selector position.
   *
   * @private
   */
  _updateFieldsFromBoxSelector() {
    /**
     * Sets the values of input fields in a vector input
     *
     * @param {Vector2Input | Vector3Input | Vector4Input} vecInput - Contains input fields for each component of a vector.
     * @param {Vector2 | Vector3| Vector4} vector - A vector from three.
     */
    const setVecInputFromVector = (vecInput, vector) => {
      vecInput.x.input.value = vector.x;
      vecInput.y.input.value = vector.y;
      if (vecInput.z) vecInput.z.input.value = vector.z;
      if (vecInput.w) vecInput.w.input.value = vector.w;
    };

    setVecInputFromVector(this.positionVec3Input, this.boxSelector.position);
    setVecInputFromVector(this.rotationVec3Input, this.boxSelector.rotation);
    setVecInputFromVector(this.scaleVec3Input, this.boxSelector.scale);
    setVecInputFromVector(
      this.countLegoVec2Input,
      new Vector2(
        Math.trunc(this.boxSelector.scale.x / this.ratio / 32),
        Math.trunc(this.boxSelector.scale.y / this.ratio / 32)
      )
    );

    this.ratioParameterLabelInput.input.value = this.ratio;
  }

  /**
   * Generates a mockup of the selected area using the specified number of Lego plates.
   */
  generateMockup() {
    const bufferBoxGeometry = this.boxSelector.geometry.clone();
    bufferBoxGeometry.applyMatrix4(this.boxSelector.matrixWorld);

    bufferBoxGeometry.computeBoundingBox();

    // Get the number of plates in the x and y directions.
    const xPlates = parseInt(this.countLegoVec2Input.x.input.value);
    const yPlates = parseInt(this.countLegoVec2Input.y.input.value);

    // Get the C3DTiles layers from the view.
    const layers = this.view.getLayers().filter((el) => el.isC3DTilesLayer);

    const mockUpObject = createMockUpObject(
      layers,
      bufferBoxGeometry.boundingBox
    );

    if (!mockUpObject || !mockUpObject.geometry) return;

    // Create a heightmap from the buffer geometry.
    const heightmap = createHeightMapFromBufferGeometry(
      mockUpObject.geometry,
      32,
      xPlates,
      yPlates
    );

    // Create a Lego mockup visualizer and add the Lego plate simulation.
    if (this.legoMockupVisualizer) this.legoMockupVisualizer.dispose();

    this.legoMockupVisualizer = new LegoMockupVisualizer(
      this.domMockUpVisualizer
    );
    this.legoMockupVisualizer.addLegoPlateSimulation(heightmap, 0, 0);

    // Generate a CSV file with the heightmap.
    generateCSVwithHeightMap(heightmap, 'legoPlates_' + 0 + '_' + 0 + '.csv');
  }

  /**
   * Toggle the selection area for a Lego model. When is enabled, you can drag the mouse to define a rectangular area in the 3D view.
   */
  selectArea() {
    this.view.controls.enabled = !this.view.controls.enabled;

    if (this.view.controls.enabled) {
      this.buttonSelectionAreaElement.textContent = 'Select an area';
      this.inputManager.setPointerLock(false);

      this.inputManager.dispose();

      this.transformCtrls.visible = true;
      this.transformCtrls.attach(this.boxSelector);
      this.transformCtrls.updateMatrixWorld();
    } else {
      this.buttonSelectionAreaElement.textContent = 'Finish';

      this.transformCtrls.detach(this.boxSelector);
      this.transformCtrls.visible = false;

      let isDragging = false;

      const geometry = new BoxGeometry(1, 1, 1);
      const material = new MeshBasicMaterial({
        color: 0x0000ff,
        opacity: 0.5,
        transparent: true,
        alphaTest: 0.5,
      });
      const selectAreaObject = new Mesh(geometry, material);

      selectAreaObject.name = 'Select Area Menu Object';

      // Compute z + height of the box
      let minZ, maxZ;

      const mouseCoordToWorldCoord = (event, result) => {
        this.view.getPickingPositionFromDepth(
          new Vector2(event.offsetX, event.offsetY),
          result
        );

        // Compute minZ maxZ according where the mouse is moving TODO check with a step in all over the rect maybe
        minZ = Math.min(minZ, result.z);
        maxZ = Math.max(maxZ, result.z);
        selectAreaObject.position.z = (minZ + maxZ) * 0.5;
        selectAreaObject.scale.z = 50 + maxZ - minZ; // 50 higher to see it
        selectAreaObject.updateMatrixWorld();
        this.view.notifyChange();
      };

      const worldCoordStart = new Vector3();
      const worldCoordCurrent = new Vector3();
      const center = new Vector3();

      const updateSelectAreaObject = () => {
        center.lerpVectors(worldCoordStart, worldCoordCurrent, 0.5);

        // Place on the x y plane
        selectAreaObject.position.x = center.x;
        selectAreaObject.position.y = center.y;

        // Compute scale
        selectAreaObject.scale.x = worldCoordCurrent.x - worldCoordStart.x;
        selectAreaObject.scale.y = worldCoordCurrent.y - worldCoordStart.y;
      };

      const dragStart = (event) => {
        if (checkParentChild(event.target, this.parentDomElement)) return; // Ui has been clicked

        isDragging = true; // Reset
        minZ = Infinity; // Reset
        maxZ = -Infinity; // Reset

        mouseCoordToWorldCoord(event, worldCoordStart);
        mouseCoordToWorldCoord(event, worldCoordCurrent);

        updateSelectAreaObject();

        this.view.scene.add(selectAreaObject);
      };
      this.inputManager.addMouseInput(
        this.view.domElement,
        'mousedown',
        dragStart
      );

      const dragging = (event) => {
        if (
          checkParentChild(event.target, this.parentDomElement) ||
          !isDragging
        )
          return; // Ui

        mouseCoordToWorldCoord(event, worldCoordCurrent);
        updateSelectAreaObject();
      };
      this.inputManager.addMouseInput(
        this.view.domElement,
        'mousemove',
        dragging
      );

      const dragEnd = () => {
        if (!isDragging) return; // Was not dragging

        this.view.scene.remove(selectAreaObject);
        isDragging = false;

        if (worldCoordStart.equals(worldCoordCurrent)) return; // It is not an area

        this.boxSelector.position.x = selectAreaObject.position.x;
        this.boxSelector.position.y = selectAreaObject.position.y;
        this.boxSelector.position.z = selectAreaObject.position.z;

        // Update scales with the size of a lego plates and the ratio chosen
        const nbPlatesX = Math.abs(
          Math.trunc(selectAreaObject.scale.x / this.ratio / 32)
        );

        const nbPlatesY = Math.abs(
          Math.trunc(selectAreaObject.scale.y / this.ratio / 32)
        );
        this.boxSelector.scale.x = nbPlatesX * this.ratio * 32;
        this.boxSelector.scale.y = nbPlatesY * this.ratio * 32;
        this.boxSelector.scale.z = Math.trunc(selectAreaObject.scale.z);
        this.legoPrevisualisation.position.x = selectAreaObject.position.x;
        this.legoPrevisualisation.position.y = selectAreaObject.position.y;
        this.legoPrevisualisation.position.z = selectAreaObject.position.z + 50;
        selectAreaObject.updateMatrixWorld();
        this.boxSelector.updateMatrixWorld();
        this.legoPrevisualisation.updateMatrixWorld();
        this.view.notifyChange(this.view.camera.camera3D);
      };
      this.inputManager.addMouseInput(this.view.domElement, 'mouseup', dragEnd);
    }
  }
}