Camera rotation around the Z axis to a point on the globe

Guys, I need you to help me please!

I have a globe (three-globe) at the center of my scene, a perspective camera that sits slightly above the horizon (and is tilted slightly down) pointing at the globe at the center of the scene, and an orbit controller.

All over the globe, I have locations - with latitude and longitude coordinates - for which I want the camera to rotate to and focus on a single location when I trigger an event. I want the camera to rotate around the Z axis only, and the selected location to come to rest in the center of the camera at the end of the rotation (in the horizontal). Even if the selected location is at the top or bottom of the globe, the camera angle and heights should not change.

How can I calculate the point that the camera moves to this point, which is on the globe?

I have tried to visualize the above once again. The small red dot represents the known location on the globe and the end point of the example. W and H are constant distances over the rotation.

ezgif.com-gif-maker

ezgif.com-gif-maker (1)
(And here is the perspective from camera view)

If you are interested in what I have done so far. This is what the project looks like up to this point.

main.js

import * as TWEEN from "@tweenjs/tween.js";
import * as THREE from "three";
import { gConf } from './config.js';
import { countriesWithHQ, Globe, initGlobe } from './globe.js';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

const domNodes = {
  map: '.mp-world__map',
  btn: '.mp-world__actions button',
};

export let renderer, camera, scene, controls;

init();
initGlobe();
animate();

// SECTION Initializing core ThreeJS elements
function init() {
  // Initialize renderer
  renderer = [new THREE.WebGLRenderer({ antialias: true }), new CSS2DRenderer()];
  renderer.forEach((r, idx) => {
    r.setSize(gConf.window.width, gConf.window.height);

    if (idx > 0) {
      // overlay additional on top of main renderer
      r.domElement.style.position = 'absolute';
      r.domElement.style.top = '0px';
      r.domElement.style.pointerEvents = 'none';
    }

    document.querySelector(domNodes.map).appendChild(r.domElement);
  });

  // Initialize scene, light
  scene = new THREE.Scene();
  scene.add(new THREE.AmbientLight(0xffffff, 1));
  scene.background = new THREE.Color(0xffffff);

  // Initialize camera, light
  camera = new THREE.PerspectiveCamera();
  camera.aspect = gConf.window.width / gConf.window.height;
  camera.position.z = 300;
  camera.updateProjectionMatrix();

  scene.add(camera);

  // Helpers
  const axesHelper = new THREE.AxesHelper(400);
  scene.add(axesHelper);
  const cameraPerspectiveHelper = new THREE.CameraHelper(camera);
  scene.add(cameraPerspectiveHelper);

  // Initialize controls
  controls = new OrbitControls(camera, renderer[0].domElement);
  controls.enableDamping = true;
  controls.dynamicDampingFactor = 0.01;
  controls.enablePan = false;
  controls.enableZoom = false;
  controls.rotateSpeed = 0.8;
  controls.zoomSpeed = 1;
  controls.autoRotate = false;

  // Adjust the angle of the globe so that all our countries are best displayed.
  const maxPoleAngle = .6;
  controls.minPolarAngle = maxPoleAngle;
  controls.maxPolarAngle = maxPoleAngle;
}

function goToCoords(country = 'DE') {
  const coordinates = countriesWithHQ.mids[country].geometry.coordinates;
  // For whatever reason, the geojson coordinates lat and lng are swapped.
  // So the first value is the longitude and the second is the latitude.
  const newPos = new THREE.Vector3(coordinates[1], coordinates[0], 0);
  // converts (lat, lng) to world position
  const coords = Globe.getCoords(coordinates[1], coordinates[0], 0)

  /* ---------------------------------------------------- */

  // Here the camera should be turned to the right position

  /* ---------------------------------------------------- */
}

function animate() {
  renderer.forEach((r) => r.render(scene, camera));
  camera.lookAt(scene.position);
  TWEEN.update();
  controls.update();
  requestAnimationFrame(animate);
}

// Button interactions
const buttons = document.querySelectorAll(domNodes.btn);

buttons.forEach(b => b.addEventListener('click', (e) => goToCoords(e.target.dataset.cid)));

globe.js

import * as THREE from "three";
import ThreeGlobe from "three-globe";
import { gConf } from './config.js';
import { camera, scene, controls } from './main.js';
import countries from './globe-data-min.json';
import * as turf from '@turf/turf'

const domNodes = {
  ctr: '.mp-world',
};

// Get globe data from the data attribute
const gData = JSON.parse(document.querySelector(domNodes.ctr).dataset.locations);

const markerSvg = `<svg viewBox="0 0 36 36">
      <circle fill="white" stroke="currentColor" cx="18" cy="18" stroke-width="3" r="16"></circle>
      <circle fill="currentColor" cx="18" cy="18" r="6"></circle>
    </svg>`;

export let Globe;

// Store all country cIds without dublicates / That means we have one or more offices
export const countriesWithHQ = { cIds: [...new Set(gData.map(d => d.cid))], mids: {} };

export function initGlobe() {
  const countriesCenterPoints = Array();

  countriesWithHQ.cIds.forEach(c => {
    // Set geojson features (country coordinates and so on) of countries where we have an office
    // Get the center lat & lng coordinates of the center of the coutries bounding box
    const midObj = getCenterCoordinates(countries.features.filter(f => f.properties.ISO_A2 === c)[0]);
    countriesWithHQ.mids[c] = midObj;
    countriesCenterPoints.push({ lat: midObj.geometry.coordinates[1], lng: midObj.geometry.coordinates[0] });
  });

  Globe = new ThreeGlobe({
    waitForGlobeReady: true,
  })
    // Adds little "dots" to the globe with represent the countries
    .hexPolygonsData(countries.features)
    .hexPolygonResolution(3)
    .hexPolygonMargin(0.35)
    // If we have a office in that given country, change the default color to our primary color
    .hexPolygonColor((e) => {
      if (countriesWithHQ.cIds.includes(e.properties.ISO_A2)) {
        return gConf.colors.primary;
      } else {
        return gConf.colors.primary_20;
      }
    })
    .showAtmosphere(true)
    .atmosphereColor(gConf.colors.primary)
    .atmosphereAltitude(0.25)

    // Places the offie markers
    .htmlAltitude(0)
    .htmlElementsData(gData)
    .htmlElement((d) => {
      const el = document.createElement('div');
      el.innerHTML = markerSvg;
      el.style.color = gConf.colors.secondary;
      el.style.width = `${gConf.marker.size}px`;

      return el;
    });

  const globeMaterial = Globe.globeMaterial();
  globeMaterial.color = new THREE.Color(0xffffff);

  Globe.setPointOfView(camera.position, Globe.position);
  controls.addEventListener('change', () => {
    Globe.setPointOfView(camera.position, Globe.position)
  });

  const lineThickness = 0.1;
  const lineAltitude = 0.2;
  const globeRadius = Globe.getGlobeRadius();
  const globeColor = gConf.colors.primary_light;
  let longMesh, latMesh;

  // Lines of longitude
  for (let n = 0; n < 24; ++n) {
    const line = new THREE.CylinderGeometry(globeRadius + lineAltitude, globeRadius + lineAltitude, lineThickness, 50, 1, true);
    line.translate(0, -lineThickness / 2, 0);
    line.rotateX(Math.PI / 2);
    line.rotateY(n * Math.PI / 12);
    longMesh = new THREE.Mesh(line, new THREE.MeshBasicMaterial({ color: globeColor }));
    Globe.add(longMesh);
  }

  // Lines of latitude
  for (let n = 1; n < 12; ++n) {
    const lat = (n - 6) * Math.PI / 12;
    const r = globeRadius * Math.cos(lat);
    const y = globeRadius * Math.sin(lat);
    const r1 = r - lineThickness * Math.sin(lat) / 2;
    const r2 = r + lineThickness * Math.sin(lat) / 2;
    const line = new THREE.CylinderGeometry(r1 + lineAltitude, r2 + lineAltitude, Math.cos(lat) * lineThickness, 50, 8, true);
    line.translate(0, -Math.cos(lat) * lineThickness / 2 + y, 0);
    latMesh = new THREE.Mesh(line, new THREE.MeshBasicMaterial({ color: globeColor }));
    Globe.add(latMesh);
  }

  scene.add(Globe);
}

function getCenterCoordinates(feature) {
  const { type, coordinates } = feature.geometry;
  let polygons;

  if (type === "Polygon") {
    polygons = turf.polygon(coordinates);
  } else {
    polygons = turf.multiPolygon(coordinates);
  }

  return turf.centroid(polygons);
}

Hey there, fellow globe designer here - my focus has been on making a realistic globe as seen from space (NASA maps with vegatation based on month, altitudes / terrain, specular water, animated clouds on an hourly basis, realistic atmosphere, night lights, meridians and parallels as well as borders at the ground level following terrain, globe rotation based on subsolar point and actual or simulated time, etc). It’s nice to work on this, irrespective if it’s a business interest or just for fun … but I also know where you took the lat / long lines code from, haha! Don’t worry, I’ve been checking out the same code, though I believe I came up with a better version. :slight_smile:

Now, on your question, there are several issues from my point of view.

First, your axes / rotation description doesn’t quite match the Three.js ones (unless you already rotated the globe to fit your way of approaching this, that is, but it doesn’t seem so from your code). This is because in your preview, the rotation is actually done around the Y (bottom to top, i.e. pole to pole in Earth terms) axis, and not around the Z (far to near, passing through the equator) axis, with the rotation around the X axis (left to right) uninvolved here.

Secondly, you didn’t post the Globe.getCoords() function, so I’ll assume you have the right code to get the world (X, Y, Z) coordinates on the globe from latitude and longitude - if not, you can check this SO question and see if they work. If they don’t (I don’t use them, but I remmeber I tried them and there were some swapped things here and there), you can use mine:

function SphericalToCartesianVector(radius, lat, lon)
{
  var spherical = new THREE.Spherical(radius, THREE.Math.degToRad(lat), THREE.Math.degToRad(lon));
  var vector = new THREE.Vector3();
  vector.setFromSpherical(spherical);
  return vector;
}

Next, once you have the position on the globe as an (X, Y, Z) vector, you can calculate where the camera should arrive using simple math. As per your preview, I’ll assume the rotation is done around the Y (and not Z) axis, so feel free to swap things if you insist it’s the Z one :slight_smile: - the Y in your preview stays constant as the height at which you rotate, so only X and Z (from the horizontal plane) change. According to basic trigonometry, the (X, Z) coordinates on the globe in that plane are (R * cos(rotationangle), R * sin(rotationangle)) where R is the radius of your globe.

Similarly, the camera should rotate around the Y axis to get at the same rotation angle on the XZ plane as the position on the globe indicates, so its final coordinates will be (D * cos(rotationangle), D * sin(rotationangle)), where D is the distance from the center of the globe to the camera, or camera.position.distanceTo(globe.position) in code. In other words, your final world coordinates of the camera will be:
(D * cos(rotationangle), yourcameraheight, D * sin(rotationangle)) [1]

Given the fact that you might not know rotationangle per se, since the latitude and longitude will be converted into an (X, Y, Z) vector as per the above function, you can deduce it from the one of the X = R * cos(rotationangle) or Z = R * sin(rotationangle) coordinates on the globe, so it’s cos(rotationangle) = X / R, thus rotationangle = arccos(X / R) [2] (and similarly, rotationangle = arcsin(Z / R) [3]). Therefore, the camera coordinates become:
D * cos(arccos(X / R)), yourcameraheight, D * sin(arcsin(Z / R))

Finally, since cos(arccos(x)) = x and likewise for its sin() counterpart, it’s just a proportional system:
Final Camera Coordinates = (D * X / R, yourcameraheight, D * Z / R) [4]
where D = camera to globe center distance, R = globe radius, X and Z = the 1st and the 3rd components of the vector given by the above function, aka the X and Z coordinates on the globe.

As for the path to get there, it’s just an arc on the circle with the radius D, you can calculate each point along that arc using [1], assuming you know the start and end rotation angles of the camera. That is easy to do since you probably know the initial rotation angle, which you can increment in radians each time you rotate the camera, or, if other independent rotations mess up with that, using either [2] or [3].

This could have probably be done in a more compact fashion using Three.js functions, but personally I don’t dig much into quaternions, rotation matrices and all that technobabble, the math I have to know to deal with common things like that is enough for me and I’m guessing for 90% of Three.js users (bar the veterans who eat such high level stuff as breakfast)…

1 Like