Measure.js

import { createLocalStorageDetails } from '@ud-viz/utils_browser';
import { round, vector3ToLabel } from '@ud-viz/utils_shared';
import {
  Group,
  Mesh,
  MeshBasicMaterial,
  Line,
  LineBasicMaterial,
  Object3D,
  SphereGeometry,
  BufferGeometry,
  Vector3,
} from 'three';

import { MAIN_LOOP_EVENTS, PlanarView } from 'itowns';
import { LayerManager } from './LayerManager';

/** @classdesc Measurement tool in a 3D visualization. Measure distances by clicking points in the scene and creating a path */
export class Measure {
  /**
   * @param {PlanarView} itownsView - managing the 3D scene.
   * @param {LayerManager} layerManager -  handling 3D tiles and intersections.
   * @param {HTMLElement} viewerDiv - 3D viewer container.
   */
  constructor(itownsView, layerManager, viewerDiv) {
    this.domElement = createLocalStorageDetails('measure_details', 'Measure');

    /** @type {HTMLButtonElement} */
    this.pathButton = null; // Toggling measure mode
    /** @type {HTMLButtonElement}  */
    this.clearMeasurePathButton = null; // Clearing the current measure path
    /** @type {HTMLDivElement} */
    this.infoPointCloudClicked = null;

    /** @type {LayerManager} */
    this.layerManager = layerManager;

    /** @type {boolean}  */
    this.modeMeasure = false; // Flag if measure is active

    /** @type {MeasurePath} */
    this.currentMeasurePath = null;

    /** @type {Group} */
    this.group = new Group();
    this.initHtml();
    this.initCallBack(viewerDiv, itownsView);
  }

  initHtml() {
    this.pathButton = document.createElement('button');
    this.domElement.appendChild(this.pathButton);

    this.clearMeasurePathButton = document.createElement('button');
    this.clearMeasurePathButton.innerText = 'Clear measure';
    this.domElement.appendChild(this.clearMeasurePathButton);

    this.infoPointCloudClicked = document.createElement('div');
    this.domElement.appendChild(this.infoPointCloudClicked);
  }

  /**
   * Initializes event callbacks for handling user interactions.
   * Handles point selection in the scene, toggling measure mode, and clearing the current path.
   *
   * @param {HTMLElement} viewerDiv - 3D viewer container.
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  initCallBack(viewerDiv, itownsView) {
    this.clearMeasurePathButton.onclick = () => {
      if (this.currentMeasurePath) this.currentMeasurePath.dispose();
      this.leaveMeasureMode(itownsView);
    };
    this.pathButton.onclick = () => {
      this.modeMeasure = !this.modeMeasure;
      this.update(itownsView);
    };

    viewerDiv.addEventListener('click', (event) => {
      const i = this.layerManager.eventTo3DTilesIntersect(
        event,
        itownsView.camera.camera3D
      );

      if (i) {
        this.infoPointCloudClicked.innerText =
          'position point cliqué = ' + vector3ToLabel(i.point);

        // if measure mode add point to path
        if (this.modeMeasure) {
          this.currentMeasurePath.addPoint(i.point, itownsView);
        }
      }
    });
  }

  /**
   * Exits the measurement mode and updates the view accordingly.
   *
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  leaveMeasureMode(itownsView) {
    if (this.modeMeasure) {
      this.modeMeasure = false;
      this.update(itownsView);
    }
  }

  /**
   * Updates the UI and behavior based on the current measurement mode.
   *
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  update(itownsView) {
    this.pathButton.innerText = this.modeMeasure
      ? 'Stop measure'
      : 'Add measure path';

    if (this.modeMeasure) {
      this.domElement.classList.add('cursor_add');
      if (this.currentMeasurePath) this.currentMeasurePath.dispose();
      this.currentMeasurePath = this.createNewMeasurePath(itownsView);
    } else {
      this.domElement.classList.remove('cursor_add');
    }
  }

  /**
   * Creates a new measurement path and adds it to the group.
   *
   * @param {PlanarView} itownsView - iTowns view instance.
   * @returns {MeasurePath} - New instance of the MeasurePath class.
   */
  createNewMeasurePath(itownsView) {
    const pointMaterial = new MeshBasicMaterial({ color: 'green' });
    const lineMaterial = new LineBasicMaterial({
      color: 0x0000ff,
      linewidth: 3,
    });
    const newMeasurePath = new MeasurePath(
      this.group,
      itownsView,
      pointMaterial,
      lineMaterial
    );
    return newMeasurePath;
  }
}

/**
 * @classdesc Measurement path composed of points and lines.
 */
class MeasurePath {
  /**
   * @param {Group} parentMeasureObject - Group that contains the path.
   * @param {PlanarView} itownsView - iTowns view instance.
   * @param {MeshBasicMaterial} pointMaterial - Material for the sphere meshes.
   * @param {LineBasicMaterial} lineMaterial - Material for the lines.
   */
  constructor(parentMeasureObject, itownsView, pointMaterial, lineMaterial) {
    /** @type {Object3D} */
    this.object3D = new Object3D();

    parentMeasureObject.add(this.object3D);

    /** @type {MeshBasicMaterial} */
    this.pointMaterial = pointMaterial;
    /** @type {LineBasicMaterial} */
    this.lineMaterial = lineMaterial;

    /** @type {Array<Mesh>} */
    this.sphereMesh = []; // array of spheres that representing points

    /** @type {Array<Label3D>} */
    this.labelDomElements = []; // array of Label3D for distances between points

    // record a frame requester
    itownsView.addFrameRequester(MAIN_LOOP_EVENTS.AFTER_CAMERA_UPDATE, () => {
      this.updateTransform(itownsView);
    });

    /** @type {Array<Vector3>} */
    this.points = []; // Array of points
  }

  /**
   * Updates the 3D objects representing the measurement path, including spheres and lines.
   *
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  update(itownsView) {
    // clear existing objects
    while (this.object3D.children.length) {
      this.object3D.remove(this.object3D.children[0]);
    }

    // add points as spheres
    for (let index = 0; index < this.points.length; index++) {
      const point = this.points[index];
      const sphere = new Mesh(new SphereGeometry(), this.pointMaterial);
      sphere.position.copy(point);

      this.sphereMesh.push(sphere);
      this.object3D.add(sphere);
    }

    // clear labels
    this.labelDomElements.forEach((l) => l.dispose());
    this.labelDomElements.length = 0;

    if (this.points.length >= 2) {
      const cloneArray = this.points.map((el) => el.clone());

      const max = new Vector3(-Infinity, -Infinity, -Infinity);
      const min = new Vector3(Infinity, Infinity, Infinity);

      // compute bb to center line object to avoid blink (when geometry has values too high)
      for (let index = 0; index < cloneArray.length; index++) {
        const point = cloneArray[index];
        max.x = Math.max(point.x, max.x);
        max.y = Math.max(point.y, max.y);
        max.z = Math.max(point.z, max.z);
        min.x = Math.min(point.x, min.x);
        min.y = Math.min(point.y, min.y);
        min.z = Math.min(point.z, min.z);

        // add distance label between points
        if (cloneArray[index + 1]) {
          this.labelDomElements.push(
            new Label3D(
              new Vector3().lerpVectors(
                cloneArray[index],
                cloneArray[index + 1],
                0.5
              ),
              round(cloneArray[index].distanceTo(cloneArray[index + 1])) + 'm'
            )
          );
        }
      }

      const center = min.lerp(max, 0.5);

      cloneArray.forEach((point) => {
        point.sub(center);
      });

      // create line from points
      const line = new Line(
        new BufferGeometry().setFromPoints(cloneArray),
        this.lineMaterial
      );

      line.position.copy(center);
      this.object3D.add(line);
    }

    this.updateTransform(itownsView);
  }

  /**
   * Updates the transform (scale) of spheres and labels based on camera distance.
   *
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  updateTransform(itownsView) {
    this.sphereMesh.forEach((s) => {
      const scale =
        itownsView.camera.camera3D.position.distanceTo(s.position) / 100;
      s.scale.set(scale, scale, scale);
    });

    // update the position of labels on the screen
    this.labelDomElements.forEach((l) => l.update(itownsView));

    itownsView.notifyChange(itownsView.camera.camera3D);
  }

  /**
   * Adds a new point to the measurement path.
   *
   * @param {Vector3} vector - position of the point.
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  addPoint(vector, itownsView) {
    this.points.push(vector);
    this.update(itownsView);
  }

  dispose() {
    this.object3D.removeFromParent();
    this.labelDomElements.forEach((l) => l.dispose());
  }
}

/**
 * @classdesc 3D label that displays information
 */
class Label3D {
  /**
   * @param {Vector3} position - Position of the label.
   * @param {string} value - text value.
   */
  constructor(position, value) {
    /** @type {Vector3} */
    this.position = position;

    /** @type {HTMLDivElement} */
    this.domElement = document.createElement('div');
    this.domElement.classList.add('label_3D');
    this.domElement.innerText = value;
  }

  dispose() {
    this.domElement.remove();
  }

  /**
   * Updates the position of the label on the screen based on the camera view.
   *
   * @param {PlanarView} itownsView - iTowns view instance.
   */
  update(itownsView) {
    const onScreenPosition = this.position.clone();
    onScreenPosition.project(itownsView.camera.camera3D);

    // compute position on screen
    // note that this is working only when parent div of the html is 100% window size
    const widthHalf =
        itownsView.mainLoop.gfxEngine.renderer.domElement.clientWidth * 0.5,
      heightHalf =
        itownsView.mainLoop.gfxEngine.renderer.domElement.clientHeight * 0.5;
    onScreenPosition.x = onScreenPosition.x * widthHalf + widthHalf;
    onScreenPosition.y = -(onScreenPosition.y * heightHalf) + heightHalf;

    this.domElement.style.left = onScreenPosition.x + 'px';
    this.domElement.style.top = onScreenPosition.y + 'px';
  }
}