Domain of rotation angle when using rotateOnWorldAxis

Hello,

I am currently working on a rotation ring that allows the user to click and drag to rotate an object.

Here is a demo: RotationTest - CodeSandbox

To rotate the object, I wait until the user clicks on the sphere, rotation ring, or plane (the plane is only visible for demo reasons, the opacity can be zero).

Here is a code excerpt from line 175:

var angle =
        Math.atan2(previousPosition.y, previousPosition.x) -
        Math.atan2(currentPosition.y, currentPosition.x);
      if (angle) {
        // Rotate the mesh by this amount
        targetMesh.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), angle);
        rotationSphereXZ.position.set(
          4 * Math.sin(targetMesh.rotation.y),
          0,
          4 * Math.cos(targetMesh.rotation.y)
        );
      }

With this rotation scheme, if you click on the sphere and try to rotate it around the entire ring, the sphere will bounce off the x-axis and not go into the negative x-direction.

ezgif.com-video-to-gif

Note that the initial rotation is triggered by clicking on the sphere, but the mouse moving rotation is controlled by raycasting the plane or the sphere.

If you replace the rotation with the following:

// targetMesh.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), angle);
// Instead use:
targetMesh.rotation.y += angle;

This works without the bouncing effect, but this is a local rotation. If I were to rotate the object on the x or z-axis, it would not rotate along just the y-axis and the rotation would become incorrect to the rotation ring scheme I am creating.

So how can I use the world rotation, without this domain issue?

Full code for demo:

import "./styles.css";

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import {
  AmbientLight,
  BoxGeometry,
  GridHelper,
  Mesh,
  MeshPhongMaterial,
  SphereGeometry,
  Vector2
} from "three";
import Stats from "stats.js";

const stats = new Stats();
stats.showPanel(0); // 0: fps, 1: ms, 2: mb, 3+: custom
document.body.appendChild(stats.dom);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

let sceneMeshes = [];

const ringGeometry = new THREE.RingGeometry(4, 3.9, 100);
const ringMaterial = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
const rotationRingXY = new THREE.Mesh(ringGeometry, ringMaterial);
const rotationRingXZ = new THREE.Mesh(ringGeometry, ringMaterial);
const rotationRingYZ = new THREE.Mesh(ringGeometry, ringMaterial);
rotationRingXZ.rotation.x = Math.PI / 2;
rotationRingYZ.rotation.y = Math.PI / 2;
var currentRotationType = "";
var mouseMoveIntersectObjects = [];

sceneMeshes.push(rotationRingXY);

let targetMesh = new Mesh(
  new BoxGeometry(1, 1, 1),
  new THREE.MeshNormalMaterial()
);

scene.add(targetMesh);

camera.position.set(1, 1, 1);
controls.update();

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

// These two vectors keep track of the mouse's position from one frame to another

let previousPosition = new THREE.Vector2();
let currentPosition = new THREE.Vector2();

let mouseMoveRaycast = false;
let mouseClickRaycast = false;

/**
 *
 * When the pointer is clicked, set up the pointer vector for the raycast
 * Enable the mouse move raycaster
 *
 * @param {*} event
 */
function onPointerClick(event) {
  // calculate pointer position in normalized device coordinates
  // (-1 to +1) for both components
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
  mouseClickRaycast = true;
  window.requestAnimationFrame(render);
}

function onPointerMove(event) {
  if (mouseMoveRaycast) {
    // calculate pointer position in normalized device coordinates
    // (-1 to +1) for both components
    pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
    pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
  }
  window.requestAnimationFrame(render);
}

// When the mouse click is released, disable the raycasting and stop rotating the object
function onMouseUp(event) {
  mouseClickRaycast = false;
  mouseMoveRaycast = false;
  previousPosition = new Vector2(0, 0);
  currentPosition = new Vector2(0, 0);
  controls.enableRotate = true;
  currentRotationType = "";
}

let rotationPlaneXZ = new THREE.Mesh(
  new THREE.PlaneGeometry(20, 20),
  new MeshPhongMaterial({
    transparent: true,
    opacity: 0.5,
    color: 0x0ffff0,
    side: THREE.DoubleSide
  })
);

// The following spheres are used as a marking of the rotation of the shape.

const rotationSphereGeometry = new SphereGeometry(0.3);
const rotationSphereMaterial = new THREE.MeshNormalMaterial();
let rotationSphereXZ = new Mesh(rotationSphereGeometry, rotationSphereMaterial);
rotationSphereXZ.position.set(
  4 * Math.sin(targetMesh.rotation.y),
  0,
  4 * Math.cos(targetMesh.rotation.y)
);

rotationPlaneXZ.rotation.x = Math.PI / 2;

scene.add(rotationPlaneXZ, rotationRingXZ, rotationSphereXZ);

scene.add(new AmbientLight());

window.addEventListener("pointermove", onPointerMove);

function render() {
  controls.update();
  /**
   * This raycaster is used to find the initial reference point for the rotation
   */
  if (mouseClickRaycast) {
    raycaster.setFromCamera(pointer, camera);
    // Only intersect with the ring.
    const intersects = raycaster.intersectObjects([rotationSphereXZ]);
    if (intersects.length > 0) {
      mouseMoveIntersectObjects = [rotationPlaneXZ, rotationSphereXZ];
      previousPosition = new THREE.Vector2(
        intersects[0].point.x,
        intersects[0].point.z
      );
      currentPosition = new THREE.Vector2(
        intersects[0].point.x,
        intersects[0].point.z
      );
      // Set the previous and current reference point as the same thing.
      // Enable the mouse move raycaster
      mouseMoveRaycast = true;
      // Disable rotation of camera for click and drag
      controls.enableRotate = false;
      mouseClickRaycast = false;
    } else {
      onMouseUp();
    }
  }
  // If the mouse raycaster is enabled, we can rotate the target mesh
  if (mouseMoveRaycast) {
    raycaster.setFromCamera(pointer, camera);
    // User can rotate on mouse move with either the rotation ring or the green plane
    const intersects = raycaster.intersectObjects(mouseMoveIntersectObjects);
    if (intersects.length > 0) {
      // Grab where the users mouse is currently
      currentPosition = new THREE.Vector2(
        intersects[0].point.x,
        intersects[0].point.z
      );
      // Calculate the change in angle between the previous intersection from mouse (or click) and this
      var angle =
        Math.atan2(previousPosition.y, previousPosition.x) -
        Math.atan2(currentPosition.y, currentPosition.x);
      if (angle) {
        // Rotate the mesh by this amount
        targetMesh.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), angle);
        // If you instead try the rotation below, it will work
        // targetMesh.rotation.y += angle;
        rotationSphereXZ.position.set(
          4 * Math.sin(targetMesh.rotation.y),
          0,
          4 * Math.cos(targetMesh.rotation.y)
        );
      }
      previousPosition = currentPosition.clone();
    }
  }
  // rotationPlaneXZ.rotation.y += 0.001;
  renderer.render(scene, camera);
  window.requestAnimationFrame(render);
}

render();

window.addEventListener("pointermove", onPointerMove);
window.addEventListener("mousedown", onPointerClick);
window.addEventListener("mouseup", onMouseUp);

// scene.add(new GridHelper());
scene.add(new THREE.AxesHelper(10, 10, 10));

The rotation property contains Euler angles. They are famous for being counterintuitive. Sometimes you change one of the angles just a bit and as a result all three angles get crazy values.

Nevertheless, here is one possible way to do the sphere motion:

Instead of lines 183…187:

rotationSphereXZ.position.set(
   4 * Math.sin(targetMesh.rotation.y),
   0,
   4 * Math.cos(targetMesh.rotation.y)
);

Try with:

rotationSphereXZ.position.copy(targetMesh.localToWorld(new THREE.Vector3(0,0,4)));
1 Like

Here is the full version of the application, with multiple rotation rings: Raycaster Ring Test - CodeSandbox

You can see that, when using the rotation scheme of:

rotationSphereXZ.position.copy(targetMesh.localToWorld(new THREE.Vector3(0,0,4)));

It will result in the spheres going off of the rings when multiple rings are rotated at once.

To try and fix this, I then manually set the y-axis to always be zero:

// Line 279
targetMesh.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), angle);
          rotationSphereXZ.position.copy(
            targetMesh.localToWorld(new THREE.Vector3(0, 0, 4))
          );
          rotationSphereXZ.position.y = 0;

But the spheres are still not be on the axis rings:

I see what you have in mind. My solution worked for the initial question, where there was a single rotation. For multiple rotations another approach is needed. I will have a look later today. If I manage to come up to a solution, I will update this post.

Edit: I’m not sure whether this is the result you need, but try with replacing the if (angle) {...} section at lines 276-298 with this code:

if (angle) {
  // Rotate the mesh by this amount
  if (currentRotationType === "XY") {
     targetMesh.rotateOnWorldAxis(new THREE.Vector3(0, 0, 1), angle);
     rotationSphereXY.position.applyAxisAngle(new THREE.Vector3(0, 0, 1), angle);
  } else if (currentRotationType === "XZ") {
     targetMesh.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), angle);
     rotationSphereXZ.position.applyAxisAngle(new THREE.Vector3(0, 1, 0), angle);
  } else if (currentRotationType === "YZ") {
     targetMesh.rotateOnWorldAxis(new THREE.Vector3(1, 0, 0), angle);
     rotationSphereYZ.position.applyAxisAngle(new THREE.Vector3(1, 0, 0), angle);
  }
}

I have tried to use your coding style. However, at some point you might consider refactoring the code – to make it shorter, easier to read and to avoid recreation of objects. For example, if you have a variable currentRotationVector holding a THREE.Vector3 value, then the whole fragment above collapses into this:

if (angle) {
  // Rotate the mesh by this amount
  targetMesh.rotateOnWorldAxis(currentRotationVector, angle);
  rotationSphereXY.position.applyAxisAngle(currentRotationVector, angle);
}
1 Like

I went through and refactored/ commented the code, as I do agree that it is needed.