Search code examples
javascripthtmlcssgeometryhtml5-canvas

How to Draw a Hyperbolic Tessellation on a Poincaré Disk given its Schläfli Symbol in JavaScript?


I am interested in the Hyperbolic Tessellations such as those generated by @TilingBot. To narrow it down a bit more, I would like to be able to construct some of the Uniform Tilings on the Hyperbolic Plane, such as this:

enter image description here

The closest answer I have found comes from Math SE and recommends these 3 resources:

  1. Ajit Datar's master's thesis
  2. David Joyce's Hyperbolic Tessellations applet
  3. And David Joyce's corresponding Java source code.

Here I have translated the Java to JavaScript (and preserved the comments), as well as attempted to draw the center shape:

class Polygon {
  constructor(n) {
    this.n = n // the number of sides
    this.v = new Array(n) // the list of vertices
  }

  static constructCenterPolygon(n, k, { quasiregular = false }) {
    // Initialize P as the center polygon in an n-k regular or quasiregular tiling.
    // Let ABC be a triangle in a regular (n,k0-tiling, where
    //    A is the center of an n-gon (also center of the disk),
    //    B is a vertex of the n-gon, and
    //    C is the midpoint of a side of the n-gon adjacent to B.
    const angleA = Math.PI / n
    const angleB = Math.PI / k
    const angleC = Math.PI / 2.0

    // For a regular tiling, we need to compute the distance s from A to B.
    let sinA = Math.sin(angleA)
    let sinB = Math.sin(angleB)
    let s = Math.sin(angleC - angleB - angleA)
      / Math.sqrt(1.0 - (sinB * sinB) - (sinA * sinA))

    // But for a quasiregular tiling, we need the distance s from A to C.

    if (quasiregular) {
      s = ((s * s) + 1.0) /  (2.0 * s * Math.cos(angleA))
      s = s - Math.sqrt((s * s) - 1.0)
    }

    // Now determine the coordinates of the n vertices of the n-gon.
    // They're all at distance s from the center of the Poincare disk.
    const polygon = new Polygon(n)
    for (let i = 0; i < n; ++i) {
      const something = (3 + 2 * i) * angleA
      const x = s * Math.cos(something)
      const y = s * Math.sin(something)
      const point = new Point(x, y)
      polygon.v[i] = point
    }
    return polygon
  }

  getScreenCoordinateArrays(dimension) {
    // first construct a list of all the points
    let pointList = null
    for (let i = 0; i < this.n; ++i) {
      const next = (i + 1) % this.n
      const line = new Line(this.v[i], this.v[next])
      pointList = line.appendScreenCoordinates(pointList, dimension)
    }

    // determine its length
    let _in = 0
    for (let pl = pointList; pl != null; pl = pl.link) {
      _in++
    }

    // now store the coordinates
    let pl = pointList
    let ix = []
    let iy = []

    for (let i = 0; i < _in; i++) {
      ix[i] = pl.x
      iy[i] = pl.y
      pl = pl.link
    }

    return { size: _in, ix, iy }
  }
}

class Line {
  constructor(a, b) {
    this.a = a // this is the line between a and b
    this.b = b

    // if it's a circle, then a center C and radius r are needed
    this.c = null
    this.r = null

    // if it's is a straight line, then a point P and a direction D
    // are needed
    this.p = null
    this.d = null

    // first determine if its a line or a circle
    let den = (a.x * b.y) - (b.x * a.y)

    this.isStraight = (Math.abs(den) < 1.0e-14)

    if (this.isStraight) {
      this.p = a; // a point on the line
      // find a unit vector D in the direction of the line
      den = Math.sqrt(
        (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)
      )
      let x = (b.x - a.x) / den
      let y = (b.y - a.y) / den
      this.d = new Point(x, y)
    } else { // it's a circle
      // find the center of the circle thru these points}
      let s1 = (1.0 + (a.x * a.x) + (a.y * a.y)) / 2.0
      let s2 = (1.0 + (b.x * b.x) + (b.y * b.y)) / 2.0
      let x = (s1 * b.y - s2 * a.y) / den
      let y = (a.x * s2 - b.x * s1) / den
      this.c = new Point (x, y)
      this.r = Math.sqrt(
          (this.c.x * this.c.x)
        + (this.c.y * this.c.y)
        - 1.0
      )
    }
  }

  // Reflect the point R thru the this line
  // to get Q the returned point
  reflect(point) {
    reflection = new Point()
    if (this.isStraight) {
      const factor = 2.0 * (
        (point.x - this.p.x)
          * this.d.x
          + (point.y - this.p.y)
          * this.d.y
        )
      reflection.x = 2.0 * this.p.x + factor * this.d.x - point.x
      reflection.y = 2.0 * this.p.y + factor * this.d.y - point.y
    } else {  // it's a circle
      const factor = (r * r) / (
          (point.x - this.c.x) * (point.x - this.c.x)
        + (point.y - this.c.y) * (point.y - this.c.y)
      )
      reflection.x = this.c.x + factor * (point.x - this.c.x)
      reflection.y = this.c.y + factor * (point.y - this.c.y)
    }
    return reflection
  }

  // append screen coordinates to the list in order to draw the line
  appendScreenCoordinates(list, dimension) {
    let x_center = dimension.width / 2
    let y_center = dimension.height / 2
    let radius = Math.min(x_center, y_center)

    let x = Math.round((this.a.x * radius) + x_center)
    let y = Math.round((this.a.y * radius) + y_center)

    const conditionA = (list == null || x != list.x || y != list.y)
    const conditionB = !isNaN(x) && !isNaN(y)

    if (conditionA && conditionB) {
      list = new ScreenCoordinateList(list, x, y)
    }

    if (this.isStraight) { // go directly to terminal point B
      x = Math.round((this.b.x * radius) + x_center)
      y = Math.round((this.b.y * radius) + y_center)
      const conditionC = x != list.x || y != list.y
      if (conditionC) {
        list = new ScreenCoordinateList(list, x, y)
      }
    } else { // its an arc of a circle
      // determine starting and ending angles
      let alpha = Math.atan2(
        this.a.y - this.c.y,
        this.a.x - this.c.x
      )

      let beta  = Math.atan2(
        this.b.y - this.c.y,
        this.b.x - this.c.x
      )

      if (Math.abs(beta - alpha) > Math.PI) {
        if (beta < alpha) {
          beta += (2.0 * Math.PI)
        }
      } else {
        alpha += (2.0 * Math.PI)
      }

      const curve = new CircularCurve(this.c.x, this.c.y, this.r)
      curve.setScreen(x_center, y_center, radius)

      list = curve.interpolate(list, alpha, beta)
    }

    return list;
  }

  draw(dimensions) {
    let x_center = dimensions.width / 2
    let y_center = dimensions.height / 2
    let radius = Math.min(x_center, y_center)
    // yet to write...
  }
}

class CircularCurve {
  // The circle in the complex plane
  constructor(x, y, r) {
    // coordinates of the center of the circle
    this.x = x
    this.y = y
    this.r = r // radius of the circle
  }

  // Add to the list the coordinates of the curve (f(t),g(t)) for t
  // between a and b. It is assumed that the point (f(a),g(a)) is
  // already on the list. Enough points will be interpolated between a
  // and b so that the approximating polygon looks like the curve.
  // The last point to be included will be (f(b),g(b)).}
  interpolate(list, a, b) {
    // first try bending it at the midpoint
    let result = this.bent(a, b, (a + b) / 2.0, list)
    if (result != list) return result

    // now try 4 random points
    for (let i = 0; i < 4; ++i) {
      const t = Math.random()
      result = this.bent(a, b, (t * a) + ((1.0 - t) * b), list)
      if (result != list) return result
    }

    // it's a straight line
    const b1 = this.xScreen(b)
    const b2 = this.yScreen(b)
    const conditionA = (list.x != b1 || list.y != b2)
    const conditionB = !isNaN(b1) && !isNaN(b2)

    if (conditionA && conditionB) {
      list = new ScreenCoordinateList(list, b1, b2)
    }

    return list // it's a straight line
  }

  // Determine if a curve between t=a and t=b is bent at t=c.
  // Say it is if C is outside a narrow ellipse.
  // If it is bent there, subdivide the interval.
  bent(a, b, c, list) {
    const a1 = this.xScreen(a)
    const a2 = this.yScreen(a)
    const b1 = this.xScreen(b)
    const b2 = this.yScreen(b)
    const c1 = this.xScreen(c)
    const c2 = this.yScreen(c)

    const excess =
        Math.sqrt((a1 - c1) * (a1 - c1) + (a2 - c2) * (a2 - c2))
      + Math.sqrt((b1 - c1) * (b1 - c1) + (b2 - c2) * (b2 - c2))
      - Math.sqrt((a1 - b1) * (a1 - b1) + (a2 - b2) * (a2 - b2))

    if (excess > 0.03) {
      list = this.interpolate(list, a, c)
      list = this.interpolate(list, c, b)
    }

    return list
  }

  setScreen(x_center, y_center, radius) {
    // screen coordinates
    this.x_center = x_center // x-coordinate of the origin
    this.y_center = y_center // y-coordinate of the origin
    this.radius = radius // distance to unit circle
  }

  xScreen(t) {
    return Math.round(this.x_center + (this.radius * this.getX(t)))
  }

  yScreen(t) {
    return Math.round(this.y_center + (this.radius * this.getY(t)))
  }

  getX(t) {
    return this.x + (this.r * Math.cos(t))
  }

  getY(t) {
    return this.y + (this.r * Math.sin(t))
  }
}

class ScreenCoordinateList {
  constructor(link, x, y) {
    // link to next one
    this.link = link
    // coordinate pair
    this.x = x
    this.y = y
  }
}

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
}
body {
  padding: 50px;
  display: flex;
  justify-content: center;
  align-items: center;
}
<canvas width="1000" height="1000"></canvas>
<script>
  window.addEventListener('load', draw)

  function draw() {
    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')

    const polygon = Polygon.constructCenterPolygon(7, 3, {quasiregular: true
    })

    const { size, ix, iy } = polygon.getScreenCoordinateArrays({
      width: 100,
      height: 100
    })

    ctx.fillStyle = '#af77e7'
    ctx.beginPath()
    ctx.moveTo(ix[0], iy[0])
    let i = 1
    while (i < size) {
      ctx.lineTo(ix[i], iy[i])
      i++
    }
    ctx.closePath()
    ctx.fill()
  }
</script>

How can I draw both the center shape and 1 or two layers out from the center (or n layers out from the center if it's straightforward) for this {7,3} hyperbolic tessellation in JavaScript on the HTML5 canvas?

I am getting this currently:

enter image description here

Ideally I'd like to draw the first hyperbolic tessellation image attached above, but if that's too complicated given what I have so far from David Joyce's work, what would be the first step, of computing the center polygon and drawing it with the fill and lines properly?


Solution

  • I would recommend you do a stroke instead of fill, that way you see what that polygon is giving you.

    Run the code below so you can see the difference...
    Now, comparing that result with your image it looks nothing like what you want

    class Polygon {
      constructor(n) {
        this.n = n // the number of sides
        this.v = new Array(n) // the list of vertices
      }
    
      static constructCenterPolygon(n, k, { quasiregular = false }) {
        // Initialize P as the center polygon in an n-k regular or quasiregular tiling.
        // Let ABC be a triangle in a regular (n,k0-tiling, where
        //    A is the center of an n-gon (also center of the disk),
        //    B is a vertex of the n-gon, and
        //    C is the midpoint of a side of the n-gon adjacent to B.
        const angleA = Math.PI / n
        const angleB = Math.PI / k
        const angleC = Math.PI / 2.0
    
        // For a regular tiling, we need to compute the distance s from A to B.
        let sinA = Math.sin(angleA)
        let sinB = Math.sin(angleB)
        let s = Math.sin(angleC - angleB - angleA)
          / Math.sqrt(1.0 - (sinB * sinB) - (sinA * sinA))
    
        // But for a quasiregular tiling, we need the distance s from A to C.
    
        if (quasiregular) {
          s = ((s * s) + 1.0) /  (2.0 * s * Math.cos(angleA))
          s = s - Math.sqrt((s * s) - 1.0)
        }
    
        // Now determine the coordinates of the n vertices of the n-gon.
        // They're all at distance s from the center of the Poincare disk.
        const polygon = new Polygon(n)
        for (let i = 0; i < n; ++i) {
          const something = (3 + 2 * i) * angleA
          const x = s * Math.cos(something)
          const y = s * Math.sin(something)
          const point = new Point(x, y)
          polygon.v[i] = point
        }
        return polygon
      }
    
      getScreenCoordinateArrays(dimension) {
        // first construct a list of all the points
        let pointList = null
        for (let i = 0; i < this.n; ++i) {
          const next = (i + 1) % this.n
          const line = new Line(this.v[i], this.v[next])
          pointList = line.appendScreenCoordinates(pointList, dimension)
        }
    
        // determine its length
        let _in = 0
        for (let pl = pointList; pl != null; pl = pl.link) {
          _in++
        }
    
        // now store the coordinates
        let pl = pointList
        let ix = []
        let iy = []
    
        for (let i = 0; i < _in; i++) {
          ix[i] = pl.x
          iy[i] = pl.y
          pl = pl.link
        }
    
        return { size: _in, ix, iy }
      }
    }
    
    class Line {
      constructor(a, b) {
        this.a = a // this is the line between a and b
        this.b = b
    
        // if it's a circle, then a center C and radius r are needed
        this.c = null
        this.r = null
    
        // if it's is a straight line, then a point P and a direction D
        // are needed
        this.p = null
        this.d = null
    
        // first determine if its a line or a circle
        let den = (a.x * b.y) - (b.x * a.y)
    
        this.isStraight = (Math.abs(den) < 1.0e-14)
    
        if (this.isStraight) {
          this.p = a; // a point on the line
          // find a unit vector D in the direction of the line
          den = Math.sqrt(
            (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)
          )
          let x = (b.x - a.x) / den
          let y = (b.y - a.y) / den
          this.d = new Point(x, y)
        } else { // it's a circle
          // find the center of the circle thru these points}
          let s1 = (1.0 + (a.x * a.x) + (a.y * a.y)) / 2.0
          let s2 = (1.0 + (b.x * b.x) + (b.y * b.y)) / 2.0
          let x = (s1 * b.y - s2 * a.y) / den
          let y = (a.x * s2 - b.x * s1) / den
          this.c = new Point (x, y)
          this.r = Math.sqrt(
              (this.c.x * this.c.x)
            + (this.c.y * this.c.y)
            - 1.0
          )
        }
      }
    
      // Reflect the point R thru the this line
      // to get Q the returned point
      reflect(point) {
        reflection = new Point()
        if (this.isStraight) {
          const factor = 2.0 * (
            (point.x - this.p.x)
              * this.d.x
              + (point.y - this.p.y)
              * this.d.y
            )
          reflection.x = 2.0 * this.p.x + factor * this.d.x - point.x
          reflection.y = 2.0 * this.p.y + factor * this.d.y - point.y
        } else {  // it's a circle
          const factor = (r * r) / (
              (point.x - this.c.x) * (point.x - this.c.x)
            + (point.y - this.c.y) * (point.y - this.c.y)
          )
          reflection.x = this.c.x + factor * (point.x - this.c.x)
          reflection.y = this.c.y + factor * (point.y - this.c.y)
        }
        return reflection
      }
    
      // append screen coordinates to the list in order to draw the line
      appendScreenCoordinates(list, dimension) {
        let x_center = dimension.width / 2
        let y_center = dimension.height / 2
        let radius = Math.min(x_center, y_center)
    
        let x = Math.round((this.a.x * radius) + x_center)
        let y = Math.round((this.a.y * radius) + y_center)
    
        const conditionA = (list == null || x != list.x || y != list.y)
        const conditionB = !isNaN(x) && !isNaN(y)
    
        if (conditionA && conditionB) {
          list = new ScreenCoordinateList(list, x, y)
        }
    
        if (this.isStraight) { // go directly to terminal point B
          x = Math.round((this.b.x * radius) + x_center)
          y = Math.round((this.b.y * radius) + y_center)
          const conditionC = x != list.x || y != list.y
          if (conditionC) {
            list = new ScreenCoordinateList(list, x, y)
          }
        } else { // its an arc of a circle
          // determine starting and ending angles
          let alpha = Math.atan2(
            this.a.y - this.c.y,
            this.a.x - this.c.x
          )
    
          let beta  = Math.atan2(
            this.b.y - this.c.y,
            this.b.x - this.c.x
          )
    
          if (Math.abs(beta - alpha) > Math.PI) {
            if (beta < alpha) {
              beta += (2.0 * Math.PI)
            }
          } else {
            alpha += (2.0 * Math.PI)
          }
    
          const curve = new CircularCurve(this.c.x, this.c.y, this.r)
          curve.setScreen(x_center, y_center, radius)
    
          list = curve.interpolate(list, alpha, beta)
        }
    
        return list;
      }
    
      draw(dimensions) {
        let x_center = dimensions.width / 2
        let y_center = dimensions.height / 2
        let radius = Math.min(x_center, y_center)
        // yet to write...
      }
    }
    
    class CircularCurve {
      // The circle in the complex plane
      constructor(x, y, r) {
        // coordinates of the center of the circle
        this.x = x
        this.y = y
        this.r = r // radius of the circle
      }
    
      // Add to the list the coordinates of the curve (f(t),g(t)) for t
      // between a and b. It is assumed that the point (f(a),g(a)) is
      // already on the list. Enough points will be interpolated between a
      // and b so that the approximating polygon looks like the curve.
      // The last point to be included will be (f(b),g(b)).}
      interpolate(list, a, b) {
        // first try bending it at the midpoint
        let result = this.bent(a, b, (a + b) / 2.0, list)
        if (result != list) return result
    
        // now try 4 random points
        for (let i = 0; i < 4; ++i) {
          const t = Math.random()
          result = this.bent(a, b, (t * a) + ((1.0 - t) * b), list)
          if (result != list) return result
        }
    
        // it's a straight line
        const b1 = this.xScreen(b)
        const b2 = this.yScreen(b)
        const conditionA = (list.x != b1 || list.y != b2)
        const conditionB = !isNaN(b1) && !isNaN(b2)
    
        if (conditionA && conditionB) {
          list = new ScreenCoordinateList(list, b1, b2)
        }
    
        return list // it's a straight line
      }
    
      // Determine if a curve between t=a and t=b is bent at t=c.
      // Say it is if C is outside a narrow ellipse.
      // If it is bent there, subdivide the interval.
      bent(a, b, c, list) {
        const a1 = this.xScreen(a)
        const a2 = this.yScreen(a)
        const b1 = this.xScreen(b)
        const b2 = this.yScreen(b)
        const c1 = this.xScreen(c)
        const c2 = this.yScreen(c)
    
        const excess =
            Math.sqrt((a1 - c1) * (a1 - c1) + (a2 - c2) * (a2 - c2))
          + Math.sqrt((b1 - c1) * (b1 - c1) + (b2 - c2) * (b2 - c2))
          - Math.sqrt((a1 - b1) * (a1 - b1) + (a2 - b2) * (a2 - b2))
    
        if (excess > 0.03) {
          list = this.interpolate(list, a, c)
          list = this.interpolate(list, c, b)
        }
    
        return list
      }
    
      setScreen(x_center, y_center, radius) {
        // screen coordinates
        this.x_center = x_center // x-coordinate of the origin
        this.y_center = y_center // y-coordinate of the origin
        this.radius = radius // distance to unit circle
      }
    
      xScreen(t) {
        return Math.round(this.x_center + (this.radius * this.getX(t)))
      }
    
      yScreen(t) {
        return Math.round(this.y_center + (this.radius * this.getY(t)))
      }
    
      getX(t) {
        return this.x + (this.r * Math.cos(t))
      }
    
      getY(t) {
        return this.y + (this.r * Math.sin(t))
      }
    }
    
    class ScreenCoordinateList {
      constructor(link, x, y) {
        // link to next one
        this.link = link
        // coordinate pair
        this.x = x
        this.y = y
      }
    }
    
    class Point {
      constructor(x, y) {
        this.x = x
        this.y = y
      }
    }
    <canvas width="400" height="400"></canvas>
    <script>
      window.addEventListener('load', draw)
    
      function draw() {
        const canvas = document.querySelector('canvas')
        const ctx = canvas.getContext('2d')
        ctx.translate(150, 150);
    
        const polygon = Polygon.constructCenterPolygon(7, 3, {quasiregular: true})
        const { size, ix, iy } = polygon.getScreenCoordinateArrays({width: 80, height: 80})
        
        for (i = 0; i < size; i++) {
          ctx.lineTo(ix[i], iy[i])
        }    
        ctx.stroke()
      }
    </script>


    But if all you are trying to address on this question is:

    what would be the first step, of computing the center polygon and drawing it

    That center polygon look like an equilateral one, the code could be much simpler, see below

    const canvas = document.getElementById('c');
    const ctx = canvas.getContext('2d');
    
    function drawEquilateralPolygon(x, y, lines, size) {
      ctx.beginPath();
      for (angle = 0; angle < 360; angle += 360 / lines) {
        a = angle * Math.PI / 180
        ctx.lineTo(x + size * Math.sin(a), y + size * Math.cos(a));
      }
      ctx.lineTo(x + size * Math.sin(0), y + size * Math.cos(0));
      ctx.stroke();
    }
    
    drawEquilateralPolygon(20, 20, 5, 20)
    drawEquilateralPolygon(60, 50, 6, 30)
    drawEquilateralPolygon(130, 70, 7, 40)
    drawEquilateralPolygon(200, 35, 8, 30)
    drawEquilateralPolygon(200, 35, 9, 25)
    drawEquilateralPolygon(200, 35, 10, 35)
    <canvas id="c"></canvas>