Source: core/core.utils.js

import Validators from './core.validators';

import { Box3 } from 'three/src/math/Box3';
import { Raycaster } from 'three/src/core/Raycaster';
import { Triangle } from 'three/src/math/Triangle';
import { Matrix4 } from 'three/src/math/Matrix4';
import { Vector3 } from 'three/src/math/Vector3';

/**
 * General purpose functions.
 *
 * @module core/utils
 */
export default class CoreUtils {
  /**
   * Generate a bouding box object.
   * @param {Vector3} center - Center of the box.
   * @param {Vector3} halfDimensions - Half Dimensions of the box.
   * @return {Object} The bounding box object. {Object.min} is a {Vector3}
   * containing the min bounds. {Object.max} is a {Vector3} containing the
   * max bounds.
   * @return {boolean} False input NOT valid.
   * @example
   * // Returns
   * //{ min: { x : 0, y : 0,  z : 0 },
   * //  max: { x : 2, y : 4,  z : 6 }
   * //}
   * VJS.Core.Utils.bbox(
   *   new Vector3(1, 2, 3), new Vector3(1, 2, 3));
   *
   * //Returns false
   * VJS.Core.Utils.bbox(new Vector3(), new Matrix4());
   *
   */
  static bbox(center, halfDimensions) {
    // make sure we have valid inputs
    if (!(Validators.vector3(center) && Validators.vector3(halfDimensions))) {
      console.log('Invalid center or plane halfDimensions.');
      return false;
    }

    // make sure half dimensions are >= 0
    if (!(halfDimensions.x >= 0 && halfDimensions.y >= 0 && halfDimensions.z >= 0)) {
      window.console.log('halfDimensions must be >= 0.');
      window.console.log(halfDimensions);
      return false;
    }

    // min/max bound
    let min = center.clone().sub(halfDimensions);
    let max = center.clone().add(halfDimensions);

    return {
      min,
      max,
    };
  }

  /**
   * Find min/max values in an array
   * @param {Array} data
   * @return {Array}
   */
  static minMax(data = []) {
    let minMax = [65535, -32768];
    let numPixels = data.length;

    for (let index = 0; index < numPixels; index++) {
      let spv = data[index];
      minMax[0] = Math.min(minMax[0], spv);
      minMax[1] = Math.max(minMax[1], spv);
    }

    return minMax;
  }

  /**
   * Check HTMLElement
   * @param {HTMLElement} obj
   * @return {boolean}
   */
  static isElement(obj) {
    try {
      // Using W3 DOM2 (works for FF, Opera and Chrom)
      return obj instanceof HTMLElement;
    } catch (e) {
      // Browsers not supporting W3 DOM2 don't have HTMLElement and
      // an exception is thrown and we end up here. Testing some
      // properties that all elements have. (works on IE7)
      return (
        typeof obj === 'object' &&
        obj.nodeType === 1 &&
        typeof obj.style === 'object' &&
        typeof obj.ownerDocument === 'object'
      );
    }
  }

  /**
   * Check string
   * @param {String} str
   * @return {Boolean}
   */
  static isString(str) {
    return typeof str === 'string' || str instanceof String;
  }

  /**
   * Parse url and find out the extension of the exam file.
   *
   * @param {*} url - The url to be parsed.
   * The query string can contain some "special" parameters that can be used to ease the parsing process
   * when the url doesn't match the exam file name on the filesystem:
   * - filename: the name of the exam file
   * - contentType: the mime type of the exam file. Currently only "application/dicom" is recognized, nifti files don't have a standard mime type.
   * For  example:
   * http://<hostname>/getExam?id=100&filename=myexam%2Enii%2Egz
   * http://<hostname>/getExam?id=100&contentType=application%2Fdicom
   *
   * @return {Object}
   */
  static parseUrl(url) {
    const parsedUrl = new URL(url, 'http://fix.me');
    const data = {
      filename: parsedUrl.searchParams.get('filename'),
      extension: '',
      pathname: parsedUrl.pathname,
      query: parsedUrl.search,
    };

    // get file name
    if (!data.filename) {
      data.filename = data.pathname.split('/').pop();
    }

    // find extension
    const splittedName = data.filename.split('.');

    data.extension = splittedName.length > 1 ? splittedName.pop() : 'dicom';

    const skipExt = [
      'asp',
      'aspx',
      'go',
      'gs',
      'hs',
      'jsp',
      'js',
      'php',
      'pl',
      'py',
      'rb',
      'htm',
      'html',
    ];

    if (
      !isNaN(data.extension) ||
      skipExt.indexOf(data.extension) !== -1 ||
      (data.query && data.query.includes('contentType=application%2Fdicom'))
    ) {
      data.extension = 'dicom';
    }

    return data;
  }

  /**
   * Compute IJK to LPS tranform.
   *  http://nipy.org/nibabel/dicom/dicom_orientation.html
   *
   * @param {*} xCos
   * @param {*} yCos
   * @param {*} zCos
   * @param {*} spacing
   * @param {*} origin
   * @param {*} registrationMatrix
   *
   * @return {*}
   */
  static ijk2LPS(xCos, yCos, zCos, spacing, origin, registrationMatrix = new Matrix4()) {
    const ijk2LPS = new Matrix4();
    ijk2LPS.set(
      xCos.x * spacing.y,
      yCos.x * spacing.x,
      zCos.x * spacing.z,
      origin.x,
      xCos.y * spacing.y,
      yCos.y * spacing.x,
      zCos.y * spacing.z,
      origin.y,
      xCos.z * spacing.y,
      yCos.z * spacing.x,
      zCos.z * spacing.z,
      origin.z,
      0,
      0,
      0,
      1
    );
    ijk2LPS.premultiply(registrationMatrix);

    return ijk2LPS;
  }

  /**
   * Compute AABB to LPS transform.
   * AABB: Axe Aligned Bounding Box.
   *
   * @param {*} xCos
   * @param {*} yCos
   * @param {*} zCos
   * @param {*} origin
   *
   * @return {*}
   */
  static aabb2LPS(xCos, yCos, zCos, origin) {
    const aabb2LPS = new Matrix4();
    aabb2LPS.set(
      xCos.x,
      yCos.x,
      zCos.x,
      origin.x,
      xCos.y,
      yCos.y,
      zCos.y,
      origin.y,
      xCos.z,
      yCos.z,
      zCos.z,
      origin.z,
      0,
      0,
      0,
      1
    );

    return aabb2LPS;
  }

  /**
   * Transform coordinates from world coordinate to data
   *
   * @param {*} lps2IJK
   * @param {*} worldCoordinates
   *
   * @return {*}
   */
  static worldToData(lps2IJK, worldCoordinates) {
    let dataCoordinate = new Vector3().copy(worldCoordinates).applyMatrix4(lps2IJK);

    // same rounding in the shaders
    dataCoordinate.addScalar(0.5).floor();

    return dataCoordinate;
  }

  static value(stack, coordinate) {
    window.console.warn('value is deprecated, please use getPixelData instead');
    this.getPixelData(stack, coordinate);
  }

  /**
   * Get voxel value
   *
   * @param {ModelsStack} stack
   * @param {Vector3} coordinate
   * @return {*}
   */
  static getPixelData(stack, coordinate) {
    if (coordinate.z >= 0 && coordinate.z < stack._frame.length) {
      return stack._frame[coordinate.z].getPixelData(coordinate.x, coordinate.y);
    } else {
      return null;
    }
  }

  /**
   * Set voxel value
   *
   * @param {ModelsStack} stack
   * @param {Vector3} coordinate
   * @param {Number} value
   * @return {*}
   */
  static setPixelData(stack, coordinate, value) {
    if (coordinate.z >= 0 && coordinate.z < stack._frame.length) {
      stack._frame[coordinate.z].setPixelData(coordinate.x, coordinate.y, value);
    } else {
      return null;
    }
  }

  /**
   * Apply slope/intercept to a value
   *
   * @param {*} value
   * @param {*} slope
   * @param {*} intercept
   *
   * @return {*}
   */
  static rescaleSlopeIntercept(value, slope, intercept) {
    return value * slope + intercept;
  }

  /**
   *
   * Convenience function to extract center of mass from list of points.
   *
   * @param {Array<Vector3>} points - Set of points from which we want to extract the center of mass.
   *
   * @returns {Vector3} Center of mass from given points.
   */
  static centerOfMass(points) {
    let centerOfMass = new Vector3(0, 0, 0);
    for (let i = 0; i < points.length; i++) {
      centerOfMass.x += points[i].x;
      centerOfMass.y += points[i].y;
      centerOfMass.z += points[i].z;
    }
    centerOfMass.divideScalar(points.length);

    return centerOfMass;
  }

  /**
   *
   * Order 3D planar points around a refence point.
   *
   * @private
   *
   * @param {Array<Vector3>} points - Set of planar 3D points to be ordered.
   * @param {Vector3} direction - Direction of the plane in which points and reference are sitting.
   *
   * @returns {Array<Object>} Set of object representing the ordered points.
   */
  static orderIntersections(points, direction) {
    let reference = this.centerOfMass(points);
    // direction from first point to reference
    let referenceDirection = new Vector3(
      points[0].x - reference.x,
      points[0].y - reference.y,
      points[0].z - reference.z
    ).normalize();

    let base = new Vector3(0, 0, 0).crossVectors(referenceDirection, direction).normalize();

    let orderedpoints = [];

    // other lines // if inter, return location + angle
    for (let j = 0; j < points.length; j++) {
      let point = new Vector3(points[j].x, points[j].y, points[j].z);
      point.direction = new Vector3(
        points[j].x - reference.x,
        points[j].y - reference.y,
        points[j].z - reference.z
      ).normalize();

      let x = referenceDirection.dot(point.direction);
      let y = base.dot(point.direction);
      point.xy = { x, y };

      let theta = Math.atan2(y, x) * (180 / Math.PI);
      point.angle = theta;

      orderedpoints.push(point);
    }

    orderedpoints.sort(function(a, b) {
      return a.angle - b.angle;
    });

    let noDups = [orderedpoints[0]];
    let epsilon = 0.0001;
    for (let i = 1; i < orderedpoints.length; i++) {
      if (Math.abs(orderedpoints[i - 1].angle - orderedpoints[i].angle) > epsilon) {
        noDups.push(orderedpoints[i]);
      }
    }

    return noDups;
  }

  /**
   * Get min, max, mean and sd of voxel values behind the mesh
   *
   * @param {THREE.Mesh}  mesh    Region of Interest
   * @param {*}           camera  Tested on CamerasOrthographic
   * @param {ModelsStack} stack
   *
   * @return {Object|null}
   */
  static getRoI(mesh, camera, stack) {
    mesh.geometry.computeBoundingBox();

    const bbox = new Box3().setFromObject(mesh);
    const min = bbox.min.clone().project(camera);
    const max = bbox.max.clone().project(camera);
    const offsetWidth = camera.controls.domElement.offsetWidth;
    const offsetHeight = camera.controls.domElement.offsetHeight;
    const rayCaster = new Raycaster();
    const values = [];

    min.x = Math.round(((min.x + 1) * offsetWidth) / 2);
    min.y = Math.round(((-min.y + 1) * offsetHeight) / 2);
    max.x = Math.round(((max.x + 1) * offsetWidth) / 2);
    max.y = Math.round(((-max.y + 1) * offsetHeight) / 2);
    [min.x, max.x] = [Math.min(min.x, max.x), Math.max(min.x, max.x)];
    [min.y, max.y] = [Math.min(min.y, max.y), Math.max(min.y, max.y)];

    let intersect = [];
    let value = null;

    for (let x = min.x; x <= max.x; x++) {
      for (let y = min.y; y <= max.y; y++) {
        rayCaster.setFromCamera(
          {
            x: (x / offsetWidth) * 2 - 1,
            y: -(y / offsetHeight) * 2 + 1,
          },
          camera
        );
        intersect = rayCaster.intersectObject(mesh);

        if (intersect.length === 0) {
          continue;
        }

        value = CoreUtils.getPixelData(
          stack,
          CoreUtils.worldToData(stack.lps2IJK, intersect[0].point)
        );

        // the image isn't RGB and coordinates are inside it
        if (value !== null && stack.numberOfChannels === 1) {
          values.push(
            CoreUtils.rescaleSlopeIntercept(value, stack.rescaleSlope, stack.rescaleIntercept)
          );
        }
      }
    }

    if (values.length === 0) {
      return null;
    }

    const avg = values.reduce((sum, val) => sum + val) / values.length;

    return {
      min: values.reduce((prev, val) => (prev < val ? prev : val)),
      max: values.reduce((prev, val) => (prev > val ? prev : val)),
      mean: avg,
      sd: Math.sqrt(values.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / values.length),
    };
  }

  /**
   * Calculate shape area (sum of triangle polygons area).
   * May be inaccurate or completely wrong for some shapes.
   *
   * @param {THREE.Geometry} geometry
   *
   * @returns {Number}
   */
  static getGeometryArea(geometry) {
    if (geometry.faces.length < 1) {
      return 0.0;
    }

    let area = 0.0;
    let vertices = geometry.vertices;

    geometry.faces.forEach(function(elem) {
      area += new Triangle(vertices[elem.a], vertices[elem.b], vertices[elem.c]).getArea();
    });

    return area;
  }

  static stringToNumber(numberAsString) {
    let number = Number(numberAsString);

    // returns true is number is NaN
    if (number !== number) {
      const dots = (numberAsString.match(/\./g)||[]).length;
      const commas = (numberAsString.match(/\,/g)||[]).length;

      if (commas === 1 && dots < 2) {
        // convert 1,45 to 1.45
        // convert 1,456.78 to 1456.78
        const replaceBy = dots === 0 ? '.' : '';
        const stringWithoutComma = numberAsString.replace(/,/g, replaceBy);
        number = Number(stringWithoutComma);
      }

      // if that didn't help
      // weird stuff happenning
      // should throw an error instead of setting value to 1.0
      if (number !== number) {
        console.error(`String could not be converted to number (${numberAsString}). Setting value to "1.0".`);
        number = 1.0;
      }
    }

    return number;
  }
}