Search code examples
javascriptmathrotationgeometryangle

What is the math to translate a plane by rotating a circle simulating a real world wheel rolling on the ground?


This is like a big secret on the internet.

In all the games I can find the wheel rotates slower or faster then it should relative to the ground movement.

Given radius of the circle and the intersection point with the plane at 90 deg angle translate the ground on X axis to simulate the real world rolling of a wheel on the ground.

In the example bellow mouse over the circle to rotate it. The ground bellow should move as you would expect in the real world.

const plane = $('#plane')
const planeX = plane.offset().left
const wheel = $('#wheel>div')
const radius = wheel.width() / 2
let degrees = 0
const offset = wheel.parent().offset()
$(document).on('mouseenter', '.interactive', event => {
  $('.interactive').css('background', 'rgba(172, 255, 47, 0.25)')
  const radians = Math.atan2(
    event.pageX - (offset.left + radius),
    event.pageY - (offset.top + radius)
  )
  const degreeWheelOffset = radians * (180 / Math.PI) * -1 - degrees // to start from where left off
  $(document).on('mousemove', event2 => {
    const radians = Math.atan2(
      event2.pageX - (offset.left + radius),
      event2.pageY - (offset.top + radius)
    )
    degrees = radians * (180 / Math.PI) * -1 - degreeWheelOffset
    wheel.css('transform', 'rotate(' + degrees + 'deg)').data('degree', degrees)
    plane.css('left', planeX - (Math.sin(radians) * radius) + 'px')
  })
})
$(document).on('mouseleave', '.interactive', () => {
  $('.interactive').css('background', '')
  $(document).off('mousemove')
})
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="wheel" style="position:fixed;bottom:1em;left:50%;transform:translateX(-50%);">
    <div style="width:150px;height:150px;border:1px solid;border-radius:50%;background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAAAAAA7suyFAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAFuSURBVDjLfdRBa8IwGMbxx7cjEklIiVQqjsoCnvb9P8FACBQKngQHXpTJhJx2mRDZoXUmqfW9WX68f5tAR1tEw3jD5/EjUPKT1e/68NRQvil/X1MUGSbXeno9LRMUGmJ1NbsgSxHFIXEBeoiSENBHlITayc4hojTUTbiJbiGrulC36WT+UWuI28U82BIjakNNlccEwPGWIwCUWy16BNm520QAk+tydsWD6f44gUSzmF6AYUSU11oNkO6cRl92cAsA+PLgX+ybfEKQHVacijOejZc/R1qJHXtChLDf5Iza0SDhvK4mBGf0ng0R3RRjEOCWeiCn1Ech27twS7V9kPOM2WqC7k6d0Xvqh1RTjnEzcEZ9JjkvxFpPcDdwRsdv5zmrK4nQwBkV5jxXmzYUGDij7kfghbBdKDRw5v52nNWlRN/cD9MztSkneGTgjNoxwAtV64DE34T27gSzhcSQgTNqz1kUAjBKvmN6h4uQ8bM/WtmCgAk7YV0AAAAASUVORK5CYII=')">
      <div class="interactive" style="border-radius:50%;position:absolute;top:0;left:0;bottom:0;right:0"></div>
      <div style="position: absolute;top:50%;bottom:0;left:50%;border-left:1px solid;"></div>
    </div>
 </div>
 <div id="plane" style="position:fixed;bottom:0;left:-100vw;width:300vw;height:1em;border:1px solid;display:flex;justify-content:space-around;background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAAAAAA7suyFAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAFuSURBVDjLfdRBa8IwGMbxx7cjEklIiVQqjsoCnvb9P8FACBQKngQHXpTJhJx2mRDZoXUmqfW9WX68f5tAR1tEw3jD5/EjUPKT1e/68NRQvil/X1MUGSbXeno9LRMUGmJ1NbsgSxHFIXEBeoiSENBHlITayc4hojTUTbiJbiGrulC36WT+UWuI28U82BIjakNNlccEwPGWIwCUWy16BNm520QAk+tydsWD6f44gUSzmF6AYUSU11oNkO6cRl92cAsA+PLgX+ybfEKQHVacijOejZc/R1qJHXtChLDf5Iza0SDhvK4mBGf0ng0R3RRjEOCWeiCn1Ech27twS7V9kPOM2WqC7k6d0Xvqh1RTjnEzcEZ9JjkvxFpPcDdwRsdv5zmrK4nQwBkV5jxXmzYUGDij7kfghbBdKDRw5v52nNWlRN/cD9MztSkneGTgjNoxwAtV64DE34T27gSzhcSQgTNqz1kUAjBKvmN6h4uQ8bM/WtmCgAk7YV0AAAAASUVORK5CYII=')"><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b><b>6</b><b>7</b><b>8</b><b>9</b></div>


Solution

  • Simple answer

    For each invocation of mousemove:

    • Compute the change in mouse coordinate from the previous invocation
    • Calculate the signed change in angle that this mouse movement produces
    • Add this to a global accumulated angle
    • Calculate the horizontal translation = (global angle in radians) * (radius of wheel)

    The signed change in angle is given by the cross-product between the vectors in the below diagram:

    enter image description here

    enter image description here

    It is better to use atan2 though, for numerical stability near the 90-degree points:

    enter image description here

    (I'm certain that at least one of the established geometry gurus here - e.g. Yves Daoust - has a post explaining how the above works, so I won't do so here.)

    Working code:

    const plane = $('#plane')
    const planeX = plane.offset().left
    const wheel = $('#wheel>div')
    const radius = wheel.width() / 2
    const offset = wheel.parent().offset()
    
    let degrees = 0;
    $(document).on('mouseenter', '.interactive', event => {
      $('.interactive').css('background', 'rgba(172, 255, 47, 0.25)')
      let mouseX1 = event.pageX, mouseY1 = event.pageY;
      $(document).on('mousemove', event2 => {
        const mouseX2 = event2.pageX, mouseY2 = event2.pageY;
    
        // center position
        const centerX = offset.left + radius, 
              centerY = offset.top + radius;
    
        // vectors A - C and B - C
        const deltaX1 = mouseX1 - centerX, deltaY1 = mouseY1 - centerY;
        const deltaX2 = mouseX2 - centerX, deltaY2 = mouseY2 - centerY;
    
        // change in angle formula
        const deltaA = Math.atan2(deltaX1 * deltaY2 - deltaY1 * deltaX2,
                                  deltaX1 * deltaX2 + deltaY1 * deltaY2);
      
        // increment
        degrees += deltaA * (180 / Math.PI);
        const radians = degrees * (Math.PI / 180);
    
        // set previous coordinates
        mouseX1 = mouseX2; mouseY1 = mouseY2;
    
        // apply
        wheel.css('transform', 'rotate(' + degrees + 'deg)').data('degree', degrees)
        plane.css('left', planeX - radians * radius + 'px') // simpler formula
      })
    })
    $(document).on('mouseleave', '.interactive', () => {
      $('.interactive').css('background', '')
      $(document).off('mousemove')
    })
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <div id="wheel" style="position:fixed;bottom:1em;left:50%;transform:translateX(-50%);">
        <div style="width:150px;height:150px;border:1px solid;border-radius:50%;background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAAAAAA7suyFAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAFuSURBVDjLfdRBa8IwGMbxx7cjEklIiVQqjsoCnvb9P8FACBQKngQHXpTJhJx2mRDZoXUmqfW9WX68f5tAR1tEw3jD5/EjUPKT1e/68NRQvil/X1MUGSbXeno9LRMUGmJ1NbsgSxHFIXEBeoiSENBHlITayc4hojTUTbiJbiGrulC36WT+UWuI28U82BIjakNNlccEwPGWIwCUWy16BNm520QAk+tydsWD6f44gUSzmF6AYUSU11oNkO6cRl92cAsA+PLgX+ybfEKQHVacijOejZc/R1qJHXtChLDf5Iza0SDhvK4mBGf0ng0R3RRjEOCWeiCn1Ech27twS7V9kPOM2WqC7k6d0Xvqh1RTjnEzcEZ9JjkvxFpPcDdwRsdv5zmrK4nQwBkV5jxXmzYUGDij7kfghbBdKDRw5v52nNWlRN/cD9MztSkneGTgjNoxwAtV64DE34T27gSzhcSQgTNqz1kUAjBKvmN6h4uQ8bM/WtmCgAk7YV0AAAAASUVORK5CYII=')">
          <div class="interactive" style="border-radius:50%;position:absolute;top:0;left:0;bottom:0;right:0"></div>
          <div style="position: absolute;top:50%;bottom:0;left:50%;border-left:1px solid;"></div>
        </div>
     </div>
     <div id="plane" style="position:fixed;bottom:0;left:-100vw;width:300vw;height:1em;border:1px solid;display:flex;justify-content:space-around;background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAAAAAA7suyFAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAFuSURBVDjLfdRBa8IwGMbxx7cjEklIiVQqjsoCnvb9P8FACBQKngQHXpTJhJx2mRDZoXUmqfW9WX68f5tAR1tEw3jD5/EjUPKT1e/68NRQvil/X1MUGSbXeno9LRMUGmJ1NbsgSxHFIXEBeoiSENBHlITayc4hojTUTbiJbiGrulC36WT+UWuI28U82BIjakNNlccEwPGWIwCUWy16BNm520QAk+tydsWD6f44gUSzmF6AYUSU11oNkO6cRl92cAsA+PLgX+ybfEKQHVacijOejZc/R1qJHXtChLDf5Iza0SDhvK4mBGf0ng0R3RRjEOCWeiCn1Ech27twS7V9kPOM2WqC7k6d0Xvqh1RTjnEzcEZ9JjkvxFpPcDdwRsdv5zmrK4nQwBkV5jxXmzYUGDij7kfghbBdKDRw5v52nNWlRN/cD9MztSkneGTgjNoxwAtV64DE34T27gSzhcSQgTNqz1kUAjBKvmN6h4uQ8bM/WtmCgAk7YV0AAAAASUVORK5CYII=')"><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b><b>6</b><b>7</b><b>8</b><b>9</b></div>


    Slightly more complex answer

    The above code makes the approximation that during a mouse polling cycle (the interval between consecutive mousemove calls) the wheel remains stationary. For continuous movement this is of course not true - the center of the wheel is constantly moving along with the mouse.

    From this arises a first-order non-linear differential equation which gives the "physically correct" delta angle per mouse movement (I won't show the derivation here):

    enter image description here

    Where m is the mouse position, p is the wheel position, and theta is the accumulated angle. With some variable substitution and re-arranging this can be solved analytically; otherwise it can be easily integrated numerically with e.g. adaptive RK4.

    ... But of course, if the polling frequency is sufficiently high (which it usually is) – i.e. the delta angle per poll cycle is small, then the approximation is more than accurate enough.