Search code examples
javascriptmathdot-productcross-product

How to curve a unit mesh between 2 unit vectors


I'm trying to draw 2 unit vectors and then draw an arc between them. I'm not looking for any solution, rather I want to know why my specific solution is not working.

First I pick 2 unit vectors at random.

function rand(min, max) {
  if (max === undefined) {
    max = min;
    min = 0;
  }
  return Math.random() * (max - min) + min;
}

var points = [{},{}];

points[0].direction = normalize([rand(-1, 1), rand(-1, 1), 0]);
points[1].direction = normalize([rand(-1, 1), rand(-1, 1), 0]);

Note: the math here is in 3D but I'm using a 2d example by just keeping the vectors in the XY plane

I can draw those 2 unit vectors in a canvas

  // move to center of canvas
  var scale = ctx.canvas.width / 2 * 0.9;
  ctx.transform(ctx.canvas.width / 2, ctx.canvas.height / 2);
  ctx.scale(scale, scale);  // expand the unit fill the canvas

  // draw a line for each unit vector
  points.forEach(function(point) {
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(point.direction[0], point.direction[1]);
    ctx.strokeStyle = point.color;
    ctx.stroke();
  });

That works.

Next I want to make a matrix that puts the XY plane with its Y axis aligned with the first unit vector and in the same plane as the plane described by the 2 unit vectors

  var zAxis = normalize(cross(points[0].direction, points[1].direction));
  var xAxis = normalize(cross(zAxis, points[0].direction));
  var yAxis = points[0].direction;

I then draw a unit grid using that matrix

ctx.setTransform(
    xAxis[0] * scale, xAxis[1] * scale,
    yAxis[0] * scale, yAxis[1] * scale,
    ctx.canvas.width / 2, ctx.canvas.height / 2);

ctx.beginPath();
for (var y = 0; y < 20; ++y) {
  var v0 = (y + 0) / 20;
  var v1 = (y + 1) / 20;
  for (var x = 0; x < 20; ++x) {
    var u0 = (x + 0) / 20;
    var u1 = (x + 1) / 20;
    ctx.moveTo(u0, v0);
    ctx.lineTo(u1, v0);
    ctx.moveTo(u0, v0);
    ctx.lineTo(u0, v1);
  }
}
ctx.stroke();

That works too. Run the sample below and see the pink unit grid is always aligned with the green unit vector and facing in the direction of the red unit vector.

Finally using the data for the unit grid I want to bend it the correct amount to fill the space between the 2 unit vectors. Given it's a unit grid it seems like I should be able to do this

var cosineOfAngleBetween = dot(points[0].direction, points[1].direction);
var expand = (1 + -cosineOfAngleBetween) / 2 * Math.PI;
var angle = x * expand;      // x goes from 0 to 1
var newX = sin(angle) * y;   // y goes from 0 to 1
var newY = cos(angle) * y;

And if I plot newX and newY for every grid point it seems like I should get the correct arc between the 2 unit vectors.

Taking the dot product of the two unit vectors should give me the cosine of the angle between them which goes from 1 if they are coincident to -1 if they are opposite. In my case I need expand to go from 0 to PI so (1 + -dot(p0, p1)) / 2 * PI seems like it should work.

But it doesn't. See the blue arc which is the unit grid points as input to the code above.

Some things I checked. I checked zAxis is correct. It's always either [0,0,1] or [0,0,-1] which is correct. I checked xAxis and yAxis are unit vectors. They are. I checked manually setting expand to PI * .5, PI, PI * 2 and it does exactly what I expect. PI * .5 gets a 90 degree arc, 1/4th of the way around from the blue unit vector. PI gets a half circle exactly as I expect. PI * 2 gets a full circle.

That makes it seem like dot(p0,p1) is wrong but looking at the dot function it seems correct and if test it with various easy vectors it returns what I expect dot([1,0,0], [1,0,0]) returns 1. dot([-1,0,0],[1,0,0]) returns -1. dot([1,0,0],[0,1,0]) returns 0. dot([1,0,0],normalize([1,1,0])) returns 0.707...

What am I missing?

Here's the code live

function cross(a, b) {
  var dst = []

  dst[0] = a[1] * b[2] - a[2] * b[1];
  dst[1] = a[2] * b[0] - a[0] * b[2];
  dst[2] = a[0] * b[1] - a[1] * b[0];

  return dst;
}

function normalize(a) {
  var dst = [];

  var lenSq = a[0] * a[0] + a[1] * a[1] + a[2] * a[2];
  var len = Math.sqrt(lenSq);
  if (len > 0.00001) {
    dst[0] = a[0] / len;
    dst[1] = a[1] / len;
    dst[2] = a[2] / len;
  } else {
    dst[0] = 0;
    dst[1] = 0;
    dst[2] = 0;
  }

  return dst;
}

function dot(a, b) {
 return (a[0] * b[0]) + (a[1] * b[1]) + (a[2] * b[2]);
}

var canvas = document.querySelector("canvas");
canvas.width = 200;
canvas.height = 200;
var ctx = canvas.getContext("2d");

function rand(min, max) {
  if (max === undefined) {
    max = min;
    min = 0;
  }
  return Math.random() * (max - min) + min;
}

var points = [
  {
    direction: [0,0,0],
    color: "green",
  },
  {
    direction: [0,0,0],
    color: "red",
  },
];

var expand = 1;
var scale = ctx.canvas.width / 2 * 0.8;

function pickPoints() {
  points[0].direction = normalize([rand(-1, 1), rand(-1, 1), 0]);
  points[1].direction = normalize([rand(-1, 1), rand(-1, 1), 0]);
  expand = (1 + -dot(points[0].direction, points[1].direction)) / 2 * Math.PI;
  console.log("expand:", expand);

  render();
}
pickPoints();

function render() {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  ctx.save();
  ctx.translate(ctx.canvas.width / 2, ctx.canvas.height / 2);
  ctx.scale(scale, scale);

  ctx.lineWidth = 3 / scale;
  points.forEach(function(point) {
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(point.direction[0], point.direction[1]);
    ctx.strokeStyle = point.color;
    ctx.stroke();
  });

  var zAxis = normalize(cross(points[0].direction, points[1].direction));
  var xAxis = normalize(cross(zAxis, points[0].direction));
  var yAxis = points[0].direction;

  ctx.setTransform(
      xAxis[0] * scale, xAxis[1] * scale,
      yAxis[0] * scale, yAxis[1] * scale,
      ctx.canvas.width / 2, ctx.canvas.height / 2);

  ctx.lineWidth = 0.5 / scale;

  ctx.strokeStyle = "pink";
  drawPatch(false);
  ctx.strokeStyle = "blue";
  drawPatch(true);

  function drawPatch(curved) {
    ctx.beginPath();
    for (var y = 0; y < 20; ++y) {
      var v0 = (y + 0) / 20;
      var v1 = (y + 1) / 20;
      for (var x = 0; x < 20; ++x) {
        var u0 = (x + 0) / 20;
        var u1 = (x + 1) / 20;
        if (curved) {
          var a0 = u0 * expand;
          var x0 = Math.sin(a0) * v0;
          var y0 = Math.cos(a0) * v0;
          var a1 = u1 * expand;
          var x1 = Math.sin(a1) * v0;
          var y1 = Math.cos(a1) * v0;
          var a2 = u0 * expand;
          var x2 = Math.sin(a0) * v1;
          var y2 = Math.cos(a0) * v1;
          ctx.moveTo(x0, y0);
          ctx.lineTo(x1, y1);
          ctx.moveTo(x0, y0);
          ctx.lineTo(x2, y2);
        } else {
          ctx.moveTo(u0, v0);
          ctx.lineTo(u1, v0);
          ctx.moveTo(u0, v0);
          ctx.lineTo(u0, v1);
        }
      }
    }
    ctx.stroke();
  }

  ctx.restore();
}

window.addEventListener('click', pickPoints);
canvas {
    border: 1px solid black;
}
div {
  display: flex;
}
<div><canvas></canvas><p> Click for new points</p></div>


Solution

  • There's nothing wrong with your dot product function. It's the way you're using it:

    expand = (1 + -dot(points[0].direction, points[1].direction)) / 2 * Math.PI;
    

    should be:

    expand = Math.acos(dot(points[0].direction, points[1].direction));
    

    The expand variable, as you use it, is an angle (in radians). The dot product gives you the cosine of the angle, but not the angle itself. While the cosine of an angle varies between 1 and -1 for input [0,pi], that value does not map linearly back to the angle itself.

    In other words, it doesn't work because the cosine of an angle cannot be transformed into the angle itself simply by scaling it. That's what arcsine is for.

    Note that in general, you can often get by using your original formula (or any simple formula that maps that [-1,1] domain to a range of [0,pi]) if all you need is an approximation, but it will never give an exact angle except at the extremes.

    This can be seen visually by plotting the two functions on top of each other:

    Source: WolframAlpha