All Downloads are FREE. Search and download functionalities are using the official Maven repository.

doodle.core.Parametric.scala Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2015 Creative Scala
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package doodle
package core

import doodle.syntax.all.*

import scala.annotation.tailrec

trait Parametric[A] extends (A => Point) {

  /** Sample `count` points uniformly along this parametric curve */
  def sample(count: Int): List[Point]
}

/** A collection of parametric curves.
  *
  * A parametric curve is a function from some input---usually a normalized
  * number or an angle---to a `Point`.
  */
object Parametric {

  /** A parametric curve that maps angles to points
    */
  final case class AngularCurve(f: Angle => Point) extends Parametric[Angle] {
    def apply(angle: Angle): Point = f(angle)

    def toNormalizedCurve(max: Angle): NormalizedCurve =
      NormalizedCurve((t: Normalized) => f((t.get * max.toRadians).radians))

    def toNormalizedCurve: NormalizedCurve =
      toNormalizedCurve(Angle.one)

    def sample(count: Int, max: Angle): List[Point] = {
      val step = max.toRadians / count

      def loop(count: Int, accum: List[Point]): List[Point] =
        count match {
          case 0 => accum
          case n => loop(n - 1, f((n * step).radians) :: accum)
        }

      loop(count, List.empty)
    }

    def sample(count: Int): List[Point] =
      sample(count, Angle.one)
  }

  /** A parametric curve that maps normalized to points
    */
  final case class NormalizedCurve(f: Normalized => Point)
      extends Parametric[Normalized] {
    def apply(t: Normalized): Point = f(t)

    /** Convert to an `AngularCurve` where the angle ranges from 0 to 360
      * degrees
      */
    def toAngularCurve: AngularCurve =
      AngularCurve((theta: Angle) => f((theta.toTurns).normalized))

    /** Sample `count` points uniformly along this parametric curve */
    def sample(count: Int): List[Point] = {
      val step = 1.0 / count

      def loop(count: Int, accum: List[Point]): List[Point] =
        count match {
          case 0 => accum
          case n => loop(n - 1, f((n * step).normalized) :: accum)
        }

      loop(count, List.empty)
    }
  }

  /** A circle */
  def circle(radius: Double): AngularCurve =
    AngularCurve((theta: Angle) => Point(radius, theta))

  /** A sinusoid */
  def sine(amplitude: Double, frequency: Double): AngularCurve =
    AngularCurve { (theta: Angle) =>
      Point(theta.toTurns, amplitude * (theta * frequency).sin)
    }

  /** Rose curve */
  def rose(k: Double, scale: Double = 1.0): AngularCurve =
    AngularCurve((theta: Angle) => Point(scale * (theta * k).cos, theta))

  /** A hypotrochoid is the curve sketched out by a point `offset` from the
    * centre of a circle of radius `innerRadius` rolling around the inside of a
    * circle of radius `outerRadius`.
    */
  def hypotrochoid(
      outerRadius: Double,
      innerRadius: Double,
      offset: Double
  ): AngularCurve = {
    val difference = outerRadius - innerRadius
    val differenceRatio = difference / innerRadius
    AngularCurve((theta: Angle) =>
      Point(
        theta.cos * difference + ((theta * differenceRatio).cos * offset),
        theta.sin * difference - ((theta * differenceRatio).sin * offset)
      )
    )
  }

  /** Logarithmic spiral */
  def logarithmicSpiral(a: Double, b: Double): AngularCurve =
    AngularCurve((theta: Angle) =>
      Point(a * Math.exp(theta.toRadians * b), theta)
    )

  /** Quadratic bezier curve */
  def quadraticBezier(start: Point, cp: Point, end: Point): NormalizedCurve = {
    // We don't use de Casteljau's algorithm because I don't think numerical stability is important for the applications we have in mind. I reserve the right to change this opinion
    NormalizedCurve((t: Normalized) => {
      val tD = t.get
      val p1 = start.toVec * (1 - tD) * (1 - tD)
      val p2 = cp.toVec * (2 * (1 - tD) * tD)
      val p3 = end.toVec * (tD * tD)

      (p1 + p2 + p3).toPoint
    })
  }

  def cubicBezier(
      start: Point,
      cp1: Point,
      cp2: Point,
      end: Point
  ): NormalizedCurve = {
    NormalizedCurve((t: Normalized) => {
      val tD = t.get
      val oneMinusTD = 1 - tD
      val p0 = start.toVec * (oneMinusTD * oneMinusTD * oneMinusTD)
      val p1 = cp1.toVec * (3 * oneMinusTD * oneMinusTD * tD)
      val p2 = cp2.toVec * (3 * oneMinusTD * tD * tD)
      val p3 = end.toVec * (tD * tD * tD)

      (p0 + p1 + p2 + p3).toPoint
    })
  }

  /** Interpolate a spline (a curve) that passes through all the given points,
    * using the Catmul Rom formulation (see, e.g.,
    * https://en.wikipedia.org/wiki/Cubic_Hermite_spline)
    *
    * The tension can be changed to control how tightly the curve turns. It
    * defaults to 0.5.
    *
    * The Catmul Rom algorithm requires a point before and after each pair of
    * points that define the curve. To meet this condition for the first and
    * last points in `points`, they are repeated.
    *
    * If `points` has less than two elements an empty `Path` is returned.
    */
  def interpolate(
      points: Seq[Point],
      tension: Double = 0.5
  ): NormalizedCurve = {
    /*
    To convert Catmul Rom curve to a Bezier curve, multiply points by (invB * catmul)

    See, for example, http://www.almightybuserror.com/2009/12/04/drawing-splines-in-opengl.html

    Inverse Bezier matrix
    val invB = Array[Double](0, 0,       0,       1,
                             0, 0,       1.0/3.0, 1,
                             0, 1.0/3.0, 2.0/3.0, 1,
                             1, 1,       1,       1)

    Catmul matrix with given tension
    val catmul = Array[Double](-tension,    2 - tension,  tension - 2,       tension,
                               2 * tension, tension - 3,  3 - (2 * tension), -tension,
                               -tension,    0,            tension,           0,
                               0,           1,            0,                 0)

    invB * catmul
    val matrix = Array[Double](0,            1,           0,           0,
                               -tension/3.0, 1,           tension/3.0, 0,
                               0,            tension/3.0, 1,           -tension/3.0,
                               0,            0,           1,           0)
     */
    def toCurve(
        pt0: Point,
        pt1: Point,
        pt2: Point,
        pt3: Point
    ): NormalizedCurve =
      cubicBezier(
        pt1,
        Point(
          ((-tension * pt0.x) + 3 * pt1.x + (tension * pt2.x)) / 3.0,
          ((-tension * pt0.y) + 3 * pt1.y + (tension * pt2.y)) / 3.0
        ),
        Point(
          ((tension * pt1.x) + 3 * pt2.x - (tension * pt3.x)) / 3.0,
          ((tension * pt1.y) + 3 * pt2.y - (tension * pt3.y)) / 3.0
        ),
        pt2
      )
    @tailrec
    def iter(
        points: Seq[Point],
        accum: Seq[NormalizedCurve]
    ): Seq[NormalizedCurve] = {
      points match {
        case pt0 +: pt1 +: pt2 +: pt3 +: pts =>
          iter(
            (pt1 +: pt2 +: pt3 +: pts),
            toCurve(pt0, pt1, pt2, pt3) +: accum
          )

        case pt0 +: pt1 +: pt2 +: Seq() =>
          // Case where we've reached the end of the sequence of points
          // We repeat the last point
          val pt3 = pt2
          toCurve(pt0, pt1, pt2, pt3) +: accum

        case _ =>
          // There were two or fewer points in the sequence
          accum
      }
    }

    val curves = iter(points, List.empty).reverse.toArray
    val size = curves.size

    /* Get the index into the curves array from t, where each curve has a 1/size share of the space */
    def index(t: Normalized): Int = {
      if t.get == 1.0 then size - 1
      else Math.floor(t.get * size).toInt
    }

    NormalizedCurve { (t: Normalized) =>
      {
        val curve = curves(index(t))
        val offset = (t.get * size) - index(t)
        curve(offset.normalized)
      }
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy