Search code examples
javascriptcssmathpolygontrigonometry

Calculate position of point along edge of regular polygon?


Say I have a pentagon (and we number the sides, like moving around a clock):

enter image description here

Starting from the center of the polygon, how do you compute the position of a point in these spots (along the line of the edge of the polygon):

  1. At the vertex between sides 2 and 3 (this is the max distance from the center).
  2. At the midpoint of 4 (this is the min distance from the center).
  3. At a point 2/3 across side 3, moving clockwise (randomly chosen distance from the center).

Knowing how to compute the x/y coordinates relative to the center will mean I can plot points along the straight line-segments of an arbitrary polygon (ranging from say 3 to 20 sides). I am having a really time conceptualizing how to do this, let alone getting it to work in code. Doesn't matter what language it is in, but preferably JavaScript/TypeScript, or else Python or C (or self-explanatory pseudocode).

Here is a combination of what I conjured up. The polygon layout works properly, but the point positioning isn't working. How do you layout these 3 points?

const ANGLE = -Math.PI / 2 // Start the first vertex at the top center

function computePolygonPoints({
  width,
  height,
  sides,
  strokeWidth = 0,
  rotation = 0,
}) {
  const centerX = width / 2 + strokeWidth / 2
  const centerY = height / 2 + strokeWidth / 2
  const radiusX = width / 2 - strokeWidth / 2
  const radiusY = height / 2 - strokeWidth / 2
  const offsetX = strokeWidth / 2
  const offsetY = strokeWidth / 2

  const rotationRad = (rotation * Math.PI) / 180

  const points = Array.from({ length: sides }, (_, i) => {
    const angle = (i * 2 * Math.PI) / sides + ANGLE
    const x = centerX + radiusX * Math.cos(angle)
    const y = centerY + radiusY * Math.sin(angle)

    // Apply rotation around the center
    const rotatedX =
      centerX +
      (x - centerX) * Math.cos(rotationRad) -
      (y - centerY) * Math.sin(rotationRad)
    const rotatedY =
      centerY +
      (x - centerX) * Math.sin(rotationRad) +
      (y - centerY) * Math.cos(rotationRad)

    return { x: rotatedX, y: rotatedY }
  })

  const minX = Math.min(...points.map(p => p.x))
  const minY = Math.min(...points.map(p => p.y))

  const adjustedPoints = points.map(p => ({
    x: offsetX + p.x - minX,
    y: offsetY + p.y - minY,
  }))

  return adjustedPoints
}

function vertexCoordinates(n, R, vertexIndex) {
  const angle = 2 * Math.PI * vertexIndex / n - Math.PI / 2; // Adjusting to start from the top
  return {
    x: R * Math.cos(angle),
    y: R * Math.sin(angle),
  }
}

function midpointCoordinates(x1, y1, x2) {
  return {
    x: (x1 + x2.x) / 2,
    y: (y1 + x2.y) / 2,
  }
}

function fractionalPoint(x1, y1, x2, fraction) {
  return {
    x: x1 + fraction * (x2.x - x1),
    y: y1 + fraction * (x2.y - y1),
  }
}

const pentagonPoints = computePolygonPoints({ width: 300, height: 300, sides: 5 })

const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", 300)
svg.setAttribute("height", 300);

const pentagon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
pentagon.setAttribute('fill', 'cyan')
pentagon.setAttribute('points', pentagonPoints
  .map((p) => `${p.x},${p.y}`)
  .join(" "))

svg.appendChild(pentagon)
document.body.appendChild(svg);

const n = 5 // Number of sides for a pentagon
const width = 300; // Width of the pentagon
const R = width / (2 * Math.cos(Math.PI / n)); // Radius of the circumscribed circle

const centerX = 150; // Center of the canvas
const centerY = 150;

// Vertex between sides 2 and 3
const vertex23 = vertexCoordinates(n, R, 2)
const vertex23Adjusted = {
    x: centerX + vertex23.x, // subtract radius too?
    y: centerY + vertex23.y
};
console.log('Vertex between sides 2 and 3:', vertex23Adjusted)

const circle23 = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle23.setAttribute('fill', 'magenta')
circle23.setAttribute('r', 16)
circle23.setAttribute('cx', vertex23Adjusted.x)
circle23.setAttribute('cy', vertex23Adjusted.y)
svg.appendChild(circle23)

// Midpoint of side 4
const vertex4_1 = vertexCoordinates(n, R, 3)
const vertex4_2 = vertexCoordinates(n, R, 4)
const mid4 = midpointCoordinates(vertex4_1.x, vertex4_1.y, vertex4_2)
console.log('Midpoint of side 4:', mid4)

const mid4Circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
mid4Circle.setAttribute('fill', 'magenta')
mid4Circle.setAttribute('r', 16)
mid4Circle.setAttribute('cx', mid4.x)
mid4Circle.setAttribute('cy', mid4.y)
svg.appendChild(mid4Circle)

// Point 2/3 across side 3, moving clockwise
const vertex3_1 = vertexCoordinates(n, R, 2)
const vertex3_2 = vertexCoordinates(n, R, 3)
const frac3 = fractionalPoint(
  vertex3_1.x,
  vertex3_1.y,
  vertex3_2,
  2 / 3,
)
console.log('Point 2/3 across side 3:', frac3)

const frac3Circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
frac3Circle.setAttribute('fill', 'magenta')
frac3Circle.setAttribute('r', 16)
frac3Circle.setAttribute('cx', frac3.x)
frac3Circle.setAttribute('cy', frac3.y)
svg.appendChild(frac3Circle)

I'd like to be able to solve this for any polygon from 3 to 20 sides, not just for the pentagon.


Solution

  • Here is the answer, I finally figured it out.

    export function calculatePolygonDotPosition({
      polygonRadius,
      polygonSideCount,
      polygonEdgeNumber,
      polygonEdgePositionRatio, // from 0 to 1
      gap = 0,
      dotRadius,
      rotation = 0,
      offset = 0,
    }: {
      polygonRadius: number
      polygonSideCount: number
      polygonEdgeNumber: number
      polygonEdgePositionRatio: number
      gap?: number
      dotRadius: number
      rotation?: number
      offset?: number
    }) {
      const n = polygonSideCount
      const R = polygonRadius
      const e = polygonEdgeNumber
      const t = polygonEdgePositionRatio
      const o = gap
    
      const rotationAngle = (rotation * Math.PI) / 180
    
      const V1 = rotatePoint(getPolygonVertex(e - 1, n, R), rotationAngle)
      const V2 = rotatePoint(getPolygonVertex(e % n, n, R), rotationAngle) // Wrap around using modulo
    
      // Interpolate position along the edge
      const P = {
        x: (1 - t) * V1.x + t * V2.x,
        y: (1 - t) * V1.y + t * V2.y,
      }
    
      // Calculate the edge vector and the normal vector
      const dx = V2.x - V1.x
      const dy = V2.y - V1.y
      const edgeLength = Math.sqrt(dx * dx + dy * dy)
    
      // Unit normal vector (rotate by 90 degrees counter-clockwise)
      const normal = {
        x: -dy / edgeLength,
        y: dx / edgeLength,
      }
    
      // Offset the point by the gap distance
      const P_offset = {
        x: P.x + (-o - dotRadius + offset) * normal.x,
        y: P.y + (-o - dotRadius + offset) * normal.y,
      }
    
      return {
        x: P_offset.x + R,
        y: -P_offset.y + R,
      }
    }
    
    // Calculate vertex positions
    export function getPolygonVertex(i: number, n: number, R: number) {
      const angle = (2 * Math.PI * i) / n + Math.PI / 2
      return { x: R * Math.cos(angle), y: R * Math.sin(angle) }
    }
    
    function rotatePoint(
      { x, y }: { x: number; y: number },
      angle: number,
    ) {
      const cos = Math.cos(angle)
      const sin = Math.sin(angle)
      return {
        x: x * cos - y * sin,
        y: x * sin + y * cos,
      }
    }
    

    Usage:

    calculatePolygonDotPosition({
      polygonRadius: 300,
      polygonSideCount: 5,
      polygonEdgeNumber: 2,
      polygonEdgePositionRatio: 0.5,
      gap: 4,
      dotRadius: 3,
      offset: 4, // strokeWidth
    })
    

    Here are 4 points laid out just outside the outer edge.

    enter image description here