modeling/src/geometries/path2/appendBezier.js

const vec2 = require('../../maths/vec2')
const vec3 = require('../../maths/vec2')

const appendPoints = require('./appendPoints')
const toPoints = require('./toPoints')

/**
 * Append a series of points to the given geometry that represent a Bezier curve.
 * The B├ęzier curve starts at the last point in the given geometry, and ends at the last control point.
 * The other control points are intermediate control points to transition the curve from start to end points.
 * The first control point may be null to ensure a smooth transition occurs. In this case,
 * the second to last point of the given geometry is mirrored into the control points of the Bezier curve.
 * In other words, the trailing gradient of the geometry matches the new gradient of the curve.
 * @param {Object} options - options for construction
 * @param {Array} options.controlPoints - list of control points (2D) for the bezier curve
 * @param {Number} [options.segment=16] - number of segments per 360 rotation
 * @param {path2} geometry - the path of which to appended points
 * @returns {path2} a new path with the appended points
 * @alias module:modeling/geometries/path2.appendBezier
 *
 * @example
 * let p5 = path2.create({}, [[10,-20]])
 * p5 = path2.appendBezier({controlPoints: [[10,-10],[25,-10],[25,-20]]}, p5);
 * p5 = path2.appendBezier({controlPoints: [null, [25,-30],[40,-30],[40,-20]]}, p5)
 */
const appendBezier = (options, geometry) => {
  const defaults = {
    segments: 16
  }
  let { controlPoints, segments } = Object.assign({}, defaults, options)

  // validate the given options
  if (!Array.isArray(controlPoints)) throw new Error('controlPoints must be an array of one or more points')
  if (controlPoints.length < 1) throw new Error('controlPoints must be an array of one or more points')

  if (segments < 4) throw new Error('segments must be four or more')

  // validate the given geometry
  if (geometry.isClosed) {
    throw new Error('the given geometry cannot be closed')
  }

  const points = toPoints(geometry)
  if (points.length < 1) {
    throw new Error('the given path must contain one or more points (as the starting point for the bezier curve)')
  }

  // make a copy of the control points
  controlPoints = controlPoints.slice()

  // special handling of null control point (only first is allowed)
  const firstControlPoint = controlPoints[0]
  if (firstControlPoint === null) {
    if (controlPoints.length < 2) {
      throw new Error('a null control point must be passed with one more control points')
    }
    // special handling of a previous bezier curve
    let lastBezierControlPoint = points[points.length - 2]
    if ('lastBezierControlPoint' in geometry) {
      lastBezierControlPoint = geometry.lastBezierControlPoint
    }
    if (!Array.isArray(lastBezierControlPoint)) {
      throw new Error('the given path must contain TWO or more points if given a null control point')
    }
    // replace the first control point with the mirror of the last bezier control point
    const controlpoint = vec2.scale(vec2.create(), points[points.length - 1], 2)
    vec2.subtract(controlpoint, controlpoint, lastBezierControlPoint)

    controlPoints[0] = controlpoint
  }

  // add a control point for the previous end point
  controlPoints.unshift(points[points.length - 1])

  const bezierOrder = controlPoints.length - 1
  const factorials = []
  let fact = 1
  for (let i = 0; i <= bezierOrder; ++i) {
    if (i > 0) fact *= i
    factorials.push(fact)
  }

  const binomials = []
  for (let i = 0; i <= bezierOrder; ++i) {
    const binomial = factorials[bezierOrder] / (factorials[i] * factorials[bezierOrder - i])
    binomials.push(binomial)
  }

  const v0 = vec2.create()
  const v1 = vec2.create()
  const v3 = vec3.create()
  const getPointForT = (t) => {
    let tk = 1 // = pow(t,k)
    let oneMinusTNMinusK = Math.pow(1 - t, bezierOrder) // = pow( 1-t, bezierOrder - k)
    const invOneMinusT = (t !== 1) ? (1 / (1 - t)) : 1
    const point = vec2.create() // 0, 0, 0
    for (let k = 0; k <= bezierOrder; ++k) {
      if (k === bezierOrder) oneMinusTNMinusK = 1
      const bernsteinCoefficient = binomials[k] * tk * oneMinusTNMinusK
      const derivativePoint = vec2.scale(v0, controlPoints[k], bernsteinCoefficient)
      vec2.add(point, point, derivativePoint)
      tk *= t
      oneMinusTNMinusK *= invOneMinusT
    }
    return point
  }

  const newpoints = []
  const newpointsT = []
  const numsteps = bezierOrder + 1
  for (let i = 0; i < numsteps; ++i) {
    const t = i / (numsteps - 1)
    const point = getPointForT(t)
    newpoints.push(point)
    newpointsT.push(t)
  }

  // subdivide each segment until the angle at each vertex becomes small enough:
  let subdivideBase = 1
  const maxangle = Math.PI * 2 / segments
  const maxsinangle = Math.sin(maxangle)
  while (subdivideBase < newpoints.length - 1) {
    const dir1 = vec2.subtract(v0, newpoints[subdivideBase], newpoints[subdivideBase - 1])
    vec2.normalize(dir1, dir1)
    const dir2 = vec2.subtract(v1, newpoints[subdivideBase + 1], newpoints[subdivideBase])
    vec2.normalize(dir2, dir2)
    const sinangle = vec2.cross(v3, dir1, dir2) // the sine of the angle
    if (Math.abs(sinangle[2]) > maxsinangle) {
      // angle is too big, we need to subdivide
      const t0 = newpointsT[subdivideBase - 1]
      const t1 = newpointsT[subdivideBase + 1]
      const newt0 = t0 + (t1 - t0) * 1 / 3
      const newt1 = t0 + (t1 - t0) * 2 / 3
      const point0 = getPointForT(newt0)
      const point1 = getPointForT(newt1)
      // remove the point at subdivideBase and replace with 2 new points:
      newpoints.splice(subdivideBase, 1, point0, point1)
      newpointsT.splice(subdivideBase, 1, newt0, newt1)
      // re - evaluate the angles, starting at the previous junction since it has changed:
      subdivideBase--
      if (subdivideBase < 1) subdivideBase = 1
    } else {
      ++subdivideBase
    }
  }

  // append to the new points to the given path
  // but skip the first new point because it is identical to the last point in the given path
  newpoints.shift()
  const result = appendPoints(newpoints, geometry)
  result.lastBezierControlPoint = controlPoints[controlPoints.length - 2]
  return result
}

module.exports = appendBezier