Danny
Danny

Reputation: 505

How do I rotate text around an oval?

I'd like to revolve text around an oval. Some slight distortion occurs on each character however the more extreme (i.e. the less of a perfect circle) the oval becomes. Ideally this method would render text nicely on an oval of any height and width ratio, and even other shapes such as rounded rectangles.

My approach so far has been to:

  1. Find the edge of the oval
  2. Rotate a point around the distorted oval using trigonometry
  3. Draw text() at this position
  4. Rotate this text() according to its position
  5. Do this over a for loop, looping over every character in a provided string.

The characters gain strange spacing (even with textWidth) and strange rotation (even when calculated dynamically). You can see that in the below snippet, particularly on the 'nn' of 'spinning' and 'ie' of 'piece'.

let canvasWidth = 400;
let canvasHeight = 400;
let spinSpeed = 0.25;
let ellipseWidth = 280;
let ellipseHeight = 200;
let angle = 0;
let sourceText = "A beautiful piece of spinning text. ";
let sourceCharacters = sourceText.toUpperCase().split("");

function setup() {
  createCanvas(canvasWidth, canvasHeight);
  angleMode(DEGREES);
  textSize(18);
  textAlign(LEFT, BASELINE);
}

function draw() {
  background(0);

  // Draw an ellipse
  stroke("rgba(255, 255, 255, 0.15)");
  noFill();
  ellipse(canvasWidth / 2, canvasHeight / 2, ellipseWidth, ellipseHeight);

  // Prepare for operations around circle
  translate(canvasWidth / 2, canvasHeight / 2);

  // Create a revolving angle
  if (angle < 360) {
    angle += spinSpeed;
  } else {
    angle = 0;
  }

  // Set variables for trigonometry
  let widthR = ellipseWidth / 2;
  let heightR = ellipseHeight / 2;
  let dx = widthR * cos(angle);
  let dy = heightR * sin(angle);

  // Set variable for offsetting each character
  let currentOffset = 0;

  // Loop through each chracter and place on oval edge
  for (let i = 0; i < sourceCharacters.length; i++) {
    push();
    dx = widthR * cos(angle + currentOffset);
    dy = heightR * sin(angle + currentOffset);

    translate(dx, dy);
    rotate(angle + currentOffset + 90);

    stroke("rgba(255, 255, 255, 0.25)");
    rect(0, 0, textWidth(sourceCharacters[i]), 10);
    fill("white");
    text(sourceCharacters[i], 0, 0);

    currentOffset += textWidth(sourceCharacters[i]);
    pop();
  }
}
html,
body {
  margin: 0;
  padding: 0;
}

canvas {
  display: block;
}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/p5.js"></script>

Upvotes: 6

Views: 565

Answers (2)

Paul Wheeler
Paul Wheeler

Reputation: 20140

This line of code does not make sense, and may only be "working" at all by coincidence:

    currentOffset += textWidth(sourceCharacters[i]);

This is using the width of a character in pixels as an angle in degrees (based on the usage of currentOffset in calls to rotate/sin/cos.

A correct implementation involves finding the tangent line of the ellipse at the point where you are drawing each character, using the slope of that tangent line to determine the angle of each character, and then determining the number of degrees of rotation necessary to space the next letter such that the corners of the glyph bounding boxes touch. Finding that letter spacing angle seems difficult to do precisely, but I have a hack that should work reasonably well.

let canvasWidth = 400;
let canvasHeight = 400;
let spinSpeed = 0.25;
let ellipseWidth = 280;
let ellipseHeight = 200;
let angle = 0;
let sourceText = "A beautiful piece of spinning text. ";
let sourceCharacters = sourceText.toUpperCase().split("");

function setup() {
  createCanvas(canvasWidth, canvasHeight);
  angleMode(DEGREES);
  textSize(18);
  // drawing each letter from it's center point makes determining the angle and
  // the spacing a little easier I think.
  textAlign(CENTER, BASELINE);
}

function draw() {
  background(0);

  // Draw an ellipse
  stroke("rgba(255, 255, 255, 0.15)");
  noFill();
  ellipse(canvasWidth / 2, canvasHeight / 2, ellipseWidth, ellipseHeight);

  // Prepare for operations around circle
  translate(canvasWidth / 2, canvasHeight / 2);

  // Create a revolving angle
  if (angle < 360) {
    angle += spinSpeed;
  } else {
    angle = 0;
  }

  // Set variables for trigonometry
  const widthR = ellipseWidth / 2;
  const heightR = ellipseHeight / 2;
  let dx, dy;

  // Set variable for offsetting each character
  let currentOffset = 0;

  // Loop through each chracter and place on oval edge
  for (let i = 0; i < sourceCharacters.length; i++) {
    push();
    /* This isn't the best way to convert an angle to a position on an ellipse
     * It causes distortions depending on the tightness of the curve.
    dx = widthR * cos(angle + currentOffset);
    dy = heightR * sin(angle + currentOffset); */

    // This give a more accurate position. See: https://math.stackexchange.com/a/2258243/771335
    let r = widthR * heightR / sqrt(widthR ** 2 * sin(angle + currentOffset) ** 2 + heightR ** 2 * cos(angle + currentOffset) ** 2);
    dx = r * cos(angle + currentOffset);
    dy = r * sin(angle + currentOffset);


    translate(dx, dy);
    // This is the derivative of the equation for an ellipse.
    // Calculating the derivative at X gives us the current slope.
    let tangent = -1 * (heightR ** 2) * dx / (widthR ** 2 * sqrt(heightR ** 2 * (widthR ** 2 - dx ** 2) / widthR ** 2));
    if (dy < 0) {
      tangent *= -1;
    }

    // Use the tangent slope to determine rotation angle
    let rotation = atan2(tangent, 1);
    if (dy > 0) {
      rotation += 180;
    }
    rotate(rotation);

    let charWidth = textWidth(sourceCharacters[i]);
    stroke("rgba(255, 255, 255, 0.25)");
    rect(-charWidth / 2, 0, charWidth, -18);
    fill("white");
    text(sourceCharacters[i], 0, 0);

    // find the angle between the vector to the center of the current letter
    // and the vector to the center of the next letter projected onto the
    // current tangent line. This is a bit of a hack.

    if (i + 1 < sourceCharacters.length) {
      let spacing = (charWidth + textWidth(sourceCharacters[i + 1])) / 2;
      let vCur = createVector(dx, dy);
      let vNext =
        vCur.copy().add(
          createVector(spacing * cos(rotation), spacing * sin(rotation))
        );

      currentOffset += vCur.angleBetween(vNext);
    }
    pop();
  }
}
html,
body {
  margin: 0;
  padding: 0;
}

canvas {
  display: block;
}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/p5.js"></script>

Upvotes: 3

James Barnett
James Barnett

Reputation: 571

From what I suspect this strange spacing between characters is not due to an error in code. It's quite simply put the same reason you can't make a very good map of earth. In p5js the ellipses and circles aren't perfect.

At some points of the ellipse, the lines within the circle that make up the pixels are longer at some points than others. I am not sure how to explain it well, but the best way I can explain it is with the same concept as with strafing in video games.

Credits to Dan Violet Sagmiller (Game Developer)

In video games, game developers sometimes lower the speed of players when they move forward and to the side at the same time (diagonally). This is because of some complicated math that shows that strafing causes a player to move faster.

The only reason that strafing happens is due to the way that game developers change player movement. Rather than using trigonometry or rotational mathematics with vectors, they use transformational logic (+, -). This usually increases performance in games. Based on your code you are also using transformational logic:

translate(dx, dy);
rotate(angle + currentOffset + 90);

Essentially, when each letter moves to its new position in each frame of your program, some letters move further than others because of the way strafing works.

I do not have any solutions, just ways to decrease the effect that you can see this by:

  • Decrease and increase sizes; you can increase the size of the ellipse or decrease the size and length of the text.

  • Keep the proportions of the ellipse similar to each-other; By keeping the width and height close to each other the effect of there being spacing between letters will be decreased.

I hope this solves your problem, or at least helps reduce the effect of it. Have a good day!

Upvotes: 2

Related Questions