modeling/src/operations/transforms/align.js

const flatten = require('../../utils/flatten')
const padArrayToLength = require('../../utils/padArrayToLength')
const measureAggregateBoundingBox = require('../../measurements/measureAggregateBoundingBox')
const { translate } = require('./translate')

const validateOptions = (options) => {
  if (!Array.isArray(options.modes) || options.modes.length > 3) throw new Error('align(): modes must be an array of length <= 3')
  options.modes = padArrayToLength(options.modes, 'none', 3)
  if (options.modes.filter((mode) => ['center', 'max', 'min', 'none'].includes(mode)).length !== 3) throw new Error('align(): all modes must be one of "center", "max" or "min"')

  if (!Array.isArray(options.relativeTo) || options.relativeTo.length > 3) throw new Error('align(): relativeTo must be an array of length <= 3')
  options.relativeTo = padArrayToLength(options.relativeTo, 0, 3)
  if (options.relativeTo.filter((alignVal) => (Number.isFinite(alignVal) || alignVal == null)).length !== 3) throw new Error('align(): all relativeTo values must be a number, or null.')

  if (typeof options.grouped !== 'boolean') throw new Error('align(): grouped must be a boolean value.')

  return options
}

const populateRelativeToFromBounds = (relativeTo, modes, bounds) => {
  for (let i = 0; i < 3; i++) {
    if (relativeTo[i] == null) {
      if (modes[i] === 'center') {
        relativeTo[i] = (bounds[0][i] + bounds[1][i]) / 2
      } else if (modes[i] === 'max') {
        relativeTo[i] = bounds[1][i]
      } else if (modes[i] === 'min') {
        relativeTo[i] = bounds[0][i]
      }
    }
  }
  return relativeTo
}

const alignGeometries = (geometry, modes, relativeTo) => {
  const bounds = measureAggregateBoundingBox(geometry)
  const translation = [0, 0, 0]
  for (let i = 0; i < 3; i++) {
    if (modes[i] === 'center') {
      translation[i] = relativeTo[i] - (bounds[0][i] + bounds[1][i]) / 2
    } else if (modes[i] === 'max') {
      translation[i] = relativeTo[i] - bounds[1][i]
    } else if (modes[i] === 'min') {
      translation[i] = relativeTo[i] - bounds[0][i]
    }
  }

  return translate(translation, geometry)
}

/**
 * Align the boundaries of the given geometries using the given options.
 * @param {Object} options - options for aligning
 * @param {Array} [options.modes = ['center', 'center', 'min']] - the point on the geometries to align to for each axis. Valid options are "center", "max", "min", and "none".
 * @param {Array} [options.relativeTo = [0,0,0]] - The point one each axis on which to align the geometries upon.  If the value is null, then the corresponding value from the group's bounding box is used.
 * @param {Boolean} [options.grouped = false] - if true, transform all geometries by the same amount, maintaining the relative positions to each other.
 * @param {...Object} geometries - the geometries to align
 * @return {Object|Array} the aligned geometry, or a list of aligned geometries
 * @alias module:modeling/transforms.align
 *
 * @example
 * let alignedGeometries = align({modes: ['min', 'center', 'none'], relativeTo: [10, null, 10], grouped: true }, geometries)
 */
const align = (options, ...geometries) => {
  const defaults = {
    modes: ['center', 'center', 'min'],
    relativeTo: [0, 0, 0],
    grouped: false
  }
  options = Object.assign({}, defaults, options)

  options = validateOptions(options)
  let { modes, relativeTo, grouped } = options
  geometries = flatten(geometries)
  if (geometries.length === 0) throw new Error('align(): No geometries were provided to act upon')

  if (relativeTo.filter((val) => val == null).length) {
    const bounds = measureAggregateBoundingBox(geometries)
    relativeTo = populateRelativeToFromBounds(relativeTo, modes, bounds)
  }
  if (grouped) {
    geometries = alignGeometries(geometries, modes, relativeTo)
  } else {
    geometries = geometries.map((geometry) => alignGeometries(geometry, modes, relativeTo))
  }
  return geometries.length === 1 ? geometries[0] : geometries
}

module.exports = align