Skip to content
This repository has been archived by the owner on Mar 8, 2023. It is now read-only.

Commit

Permalink
HARP-12026 Apply tilt limit also to rotation center (#1885)
Browse files Browse the repository at this point in the history
Signed-off-by: Jonathan Stichbury <2533428+nzjony@users.noreply.github.com>
  • Loading branch information
nzjony committed Oct 22, 2020
1 parent 6cb460c commit da4ee0b
Show file tree
Hide file tree
Showing 2 changed files with 223 additions and 73 deletions.
207 changes: 176 additions & 31 deletions @here/harp-mapview/lib/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
OrientedBox3,
Projection,
ProjectionType,
sphereProjection,
TileKey,
Vector3Like
} from "@here/harp-geoutils";
Expand Down Expand Up @@ -242,53 +243,190 @@ export namespace MapViewUtils {
deltaTilt: number,
maxTiltAngle: number
) {
const camera = mapView.camera;
const projection = mapView.projection;

const rotationTargetWorld = MapViewUtils.rayCastWorldCoordinates(mapView, offsetX, offsetY);
if (rotationTargetWorld === null) {
return;
}

const headingAxis = projection.surfaceNormal(rotationTargetWorld, cache.vector3[1]);
const headingQuat = cache.quaternions[1].setFromAxisAngle(headingAxis, -deltaAzimuth);
camera.quaternion.premultiply(headingQuat);
camera.position.sub(rotationTargetWorld);
camera.position.applyQuaternion(headingQuat);
camera.position.add(rotationTargetWorld);

const mapTargetWorld =
offsetX === 0 && offsetY === 0
? rotationTargetWorld
: MapViewUtils.rayCastWorldCoordinates(mapView, 0, 0);
if (mapTargetWorld === null) {
return;
}
const mapTargetNormal = projection.surfaceNormal(mapTargetWorld, new THREE.Vector3());

camera.updateMatrixWorld(true);
const dir = cache.vector3[1].setFromMatrixColumn(camera.matrixWorld, 2);
const up = cache.vector3[2].setFromMatrixColumn(camera.matrixWorld, 1);
applyAzimuthAroundTarget(mapView, rotationTargetWorld, -deltaAzimuth);

// Due to inaccuracies it can happen that the tilt angle gets less than 0.
// Using the dot product of the direction vector and the surface normal alone does not give us the sign.
const tiltSign = Math.sign(up.dot(mapTargetNormal));
const tiltAxis = new THREE.Vector3(1, 0, 0).applyQuaternion(mapView.camera.quaternion);
const clampedDeltaTilt = computeClampedDeltaTilt(
mapView,
offsetY,
deltaTilt,
maxTiltAngle,
mapTargetWorld,
rotationTargetWorld,
tiltAxis
);

const currentTilt = (tiltSign === 0 ? 1 : tiltSign) * dir.angleTo(mapTargetNormal);
applyTiltAroundTarget(mapView, rotationTargetWorld, clampedDeltaTilt, tiltAxis);
}

//FIXME(HARP-11926): For globe tilt in the map center is different from the tilt in the rotation center,
//hence the clamped tilt is too conservative.
const clampedDeltaTilt =
MathUtils.clamp(deltaTilt + currentTilt, 0, maxTiltAngle) - currentTilt;
if (Math.abs(clampedDeltaTilt) <= Number.EPSILON) {
return;
/**
* @hidden
* @internal
*
* Applies the given Azimith to the camera around the supplied target.
*/
function applyAzimuthAroundTarget(
mapView: MapView,
rotationTargetWorld: THREE.Vector3,
deltaAzimuth: number
) {
const camera = mapView.camera;
const projection = mapView.projection;

const headingAxis = projection.surfaceNormal(rotationTargetWorld, cache.vector3[0]);
const headingQuat = cache.quaternions[0].setFromAxisAngle(headingAxis, deltaAzimuth);
camera.quaternion.premultiply(headingQuat);
camera.position.sub(rotationTargetWorld);
camera.position.applyQuaternion(headingQuat);
camera.position.add(rotationTargetWorld);
}

/**
* @hidden
* @internal
*
* Clamps the supplied `deltaTilt` to the `maxTiltAngle` supplied. Note, when a non-zero offset
* is applied, we apply another max angle of 89 degrees to the rotation center to prevent some
* corner cases where the angle at the rotation center is 90 degrees and therefore intersects
* the geometry with the near plane.
*/
function computeClampedDeltaTilt(
mapView: MapView,
offsetY: number,
deltaTilt: number,
maxTiltAngle: number,
mapTargetWorld: THREE.Vector3,
rotationTargetWorld: THREE.Vector3,
tiltAxis: THREE.Vector3
): number {
const camera = mapView.camera;
const projection = mapView.projection;

const tilt = extractTiltAngleFromLocation(projection, camera, mapTargetWorld, tiltAxis);
if (tilt + deltaTilt < 0) {
// Clamp the final tilt to 0
return -tilt;
} else if (deltaTilt <= 0) {
// Reducing the tilt isn't clamped (apart from above).
return deltaTilt;
} else if (mapTargetWorld.equals(rotationTargetWorld) || offsetY < 0) {
// When the rotation target is the center, or the offsetY is < 0, i.e. the angle at the
// `mapTargetWorld` is always bigger, then we have a simple formula
return MathUtils.clamp(deltaTilt + tilt, 0, maxTiltAngle) - tilt;
}

const rotationCenterTilt = extractTiltAngleFromLocation(
projection,
camera,
rotationTargetWorld!,
tiltAxis
);

const maxRotationTiltAngle = THREE.MathUtils.degToRad(89);

// The rotationCenterTilt may exceed 89 degrees when for example the user has tilted to 89
// at the mapTargetWorld, then choose a rotation center target above the mapTargetWorld,
// i.e. offsetY > 0. In such case, we just return 0, i.e. we don't let the user increase
// the tilt (but it can decrease, see check above for "deltaTilt <= 0").
if (rotationCenterTilt > maxRotationTiltAngle) {
return 0;
}

const posBackup = cache.vector3[1].copy(camera.position);
const quatBackup = cache.quaternions[1].copy(camera.quaternion);
// This is used to find the max tilt angle, because the difference in normals is needed
// to correct the triangle used to find the max tilt angle at the rotation center.
let angleBetweenNormals = 0;
if (projection === sphereProjection) {
const projectedRotationTargetNormal = projection
.surfaceNormal(rotationTargetWorld, cache.vector3[0])
.projectOnPlane(tiltAxis)
.normalize();
const mapTargetNormal = projection.surfaceNormal(mapTargetWorld, cache.vector3[1]);
angleBetweenNormals = projectedRotationTargetNormal.angleTo(mapTargetNormal);
}

const ninetyRad = THREE.MathUtils.degToRad(90);

// The following terminology will be used:
// Ta = Tilt axis, tilting is achieved by rotating the camera around this direction.
// R = rotation target, i.e. the point about which we are rotating: `rotationTargetWorld`
// Rp = rotation target projected on to Ta
// C = camera position
// M = map target, i.e. the point which the camera is looking at at the NDC coordinates 0,0

// Note, the points Rp, C, and M create a plane that is perpendicular to the earths surface,
// because the tilt axis is perpendicular to the up vector. The following variable `RpCM` is
// the angle between the two rays C->Rp and C->M. This angle remains constant when tilting
// with a fixed `offsetX` and `offsetY`. It is calculated by using the intersection of the
// two rays with the earth.

// Note the use of `angleBetweenNormals` to ensure this works for spherical projections.
// Note, this calculation only works when the tilt at M is less than the tilt
// at Rp, otherwise the above formula won't work. We however don't need to worry about this
// case because this happens only when offsetY is less than zero, and this is handled above.
const MRpC = ninetyRad + angleBetweenNormals - rotationCenterTilt;
const CMRp = ninetyRad + tilt;
const RpCM = ninetyRad * 2 - (MRpC + CMRp);

// We want to find the greatest angle at the rotation target that gives us the max
// angle at the map center target.
const CMRpMaxTilt = ninetyRad * 2 - RpCM - ninetyRad - maxTiltAngle;

// Converting the `MRpC` back to a tilt is as easy as subtracting it from 90 and the
// `angleBetweenNormals`, i.e. this gives us the maximum allowed tilt at R that satisfies
// the `maxTiltAngle` constraint. Note, for globe projection, this is just an approximation,
// because once we move the camera by delta, the map target changes, and therefore the
// normal also changes, this would need to be applied iteratively until the difference in
// normals is reduced to some epsilon. I don't apply this because it is computationally
// expensive and the user would never notice this in practice.
const maxTilt = ninetyRad + angleBetweenNormals - CMRpMaxTilt;

// Here we clamp to the min of `maxTilt` and 89 degrees. The check for 89 is to prevent it
// intersecting with the world at 90. This is possible for example when the R position is
// near the horizon. If the angle RCM is say 5 degrees, then an angle of say 89 degrees at
// R, plus 5 degrees means the tilt at M would be 84 degrees, so the camera can reach 90
// from the point R whilst the tilt to M never reaches the `maxTiltAngle`
const clampedDeltaTilt =
MathUtils.clamp(
deltaTilt + rotationCenterTilt,
0,
Math.min(maxTilt, maxRotationTiltAngle)
) - rotationCenterTilt;

return clampedDeltaTilt;
}

/**
* @hidden
* @internal
*
* Applies the given tilt to the camera around the supplied target.
*/
function applyTiltAroundTarget(
mapView: MapView,
rotationTargetWorld: THREE.Vector3,
deltaTilt: number,
tiltAxis: THREE.Vector3
) {
const camera = mapView.camera;
// Consider to use the cache if necessary, but beware, because the `rayCastWorldCoordinates`
// also uses this cache.
const posBackup = camera.position.clone();
const quatBackup = camera.quaternion.clone();

const tiltAxis = cache.vector3[0].set(1, 0, 0).applyQuaternion(camera.quaternion);
const tiltQuat = cache.quaternions[0].setFromAxisAngle(tiltAxis, clampedDeltaTilt);
const tiltQuat = cache.quaternions[0].setFromAxisAngle(tiltAxis, deltaTilt);
camera.quaternion.premultiply(tiltQuat);
camera.position.sub(rotationTargetWorld);
camera.position.applyQuaternion(tiltQuat);
Expand Down Expand Up @@ -1319,21 +1457,28 @@ export namespace MapViewUtils {
* converting from geo to world coordinates.
* @param object - The object to get the coordinates from.
* @param location - The reference point.
* @param tiltAxis - Optional axis used to define the rotation about which the object's tilt
* occurs, the direction vector to the location from the camera is projected on the plane with
* the given angle.
*/
export function extractTiltAngleFromLocation(
projection: Projection,
object: THREE.Object3D,
location: GeoCoordinates
location: GeoCoordinates | Vector3Like,
tiltAxis?: THREE.Vector3
): number {
projection.localTangentSpace(location, {
xAxis: tangentSpace.x,
yAxis: tangentSpace.y,
zAxis: tangentSpace.z,
position: cache.vector3[0]
});

// Get point to object vector (dirVec) and compute the `tilt` as the angle with tangent Z.
const dirVec = cache.vector3[1].copy(object.position).sub(cache.vector3[0]);
const dirVec = cache.vector3[2].copy(object.position).sub(cache.vector3[0]);
if (tiltAxis) {
dirVec.projectOnPlane(tiltAxis);
tangentSpace.z.projectOnPlane(tiltAxis).normalize();
}
const dirLen = dirVec.length();
if (dirLen < epsilon) {
logger.error("Can not calculate tilt for the zero length vector!");
Expand Down
89 changes: 47 additions & 42 deletions @here/harp-mapview/test/UtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { assert, expect } from "chai";
import * as sinon from "sinon";
import * as THREE from "three";
import { getProjectionName } from "@here/harp-datasource-protocol";
import { Camera, Vector3 } from "three";

function setCamera(
camera: THREE.Camera,
Expand Down Expand Up @@ -55,6 +56,7 @@ function setCamera(
}

describe("MapViewUtils", function() {
const EPS = 1e-8;
describe("zoomOnTargetPosition", function() {
const mapViewMock = {
maxZoomLevel: 20,
Expand Down Expand Up @@ -218,43 +220,48 @@ describe("MapViewUtils", function() {
);
});
it("limits tilt when orbiting around screen point", function() {
setCamera(
mapView.camera,
mapView.projection,
target,
0, // heading
0, // tilt
MapViewUtils.calculateDistanceFromZoomLevel(mapView, 4)
);

const deltaTilt = THREE.MathUtils.degToRad(46);
const deltaHeading = 0;
const offsetX = 0.2;
const offsetY = 0.5;

MapViewUtils.orbitAroundScreenPoint(
mapView,
offsetX,
offsetY,
deltaHeading,
deltaTilt,
tiltLimit
);
for (const startTilt of [0, 20, 45]) {
setCamera(
mapView.camera,
mapView.projection,
target,
0, // heading
startTilt, // tilt
MapViewUtils.calculateDistanceFromZoomLevel(mapView, 4)
);

const mapTargetWorld = MapViewUtils.rayCastWorldCoordinates(mapView, 0, 0);
expect(mapTargetWorld).to.not.be.null;
const deltaTilt = THREE.MathUtils.degToRad(46);
const deltaHeading = 0;
// OffsetX must be 0 for this to work for Sphere & Mercator, when this is non-zero,
// it works for planar, but not sphere.
const offsetX = 0.1;
const offsetY = 0.1;

MapViewUtils.orbitAroundScreenPoint(
mapView,
offsetX,
offsetY,
deltaHeading,
// Delta is past the tilt limit.
deltaTilt,
tiltLimit
);
const mapTargetWorldNew = MapViewUtils.rayCastWorldCoordinates(mapView, 0, 0);

const { tilt } = MapViewUtils.extractSphericalCoordinatesFromLocation(
mapView,
mapView.camera,
mapTargetWorld!
);
if (projection === sphereProjection) {
//FIXME(HARP-11926): For globe tilt in the map center is different from the tilt
// in the rotation center, hence the clamped tilt is too conservative.
expect(tilt).to.be.lessThan(tiltLimit);
} else {
expect(tilt).to.be.closeTo(tiltLimit, Number.EPSILON);
const afterTilt = MapViewUtils.extractTiltAngleFromLocation(
mapView.projection,
mapView.camera,
mapTargetWorldNew!
);
if (projection === sphereProjection) {
if (afterTilt > tiltLimit) {
// If greater, then only within EPS, otherwise it should be less.
expect(afterTilt).to.be.closeTo(tiltLimit, EPS);
}
} else {
// Use a custom EPS, Number.Epsilon is too strict for such maths
expect(afterTilt).to.be.closeTo(tiltLimit, EPS);
}
}
});
it("keeps rotation target when orbiting around screen point", function() {
Expand Down Expand Up @@ -287,21 +294,19 @@ describe("MapViewUtils", function() {
tiltLimit
);

const newRoationTarget = MapViewUtils.rayCastWorldCoordinates(
const newRotationTarget = MapViewUtils.rayCastWorldCoordinates(
mapView,
offsetX,
offsetY
);
expect(newRoationTarget).to.be.not.null;
expect(newRotationTarget).to.be.not.null;

expect(oldRotationTarget!.distanceTo(newRoationTarget!)).to.be.closeTo(
0,
projection === sphereProjection ? 1e-9 : Number.EPSILON
);
const distance = oldRotationTarget!.distanceTo(newRotationTarget!);
expect(distance).to.be.closeTo(0, EPS);

// Also check that we did not introduce any roll
const { roll } = MapViewUtils.extractAttitude(mapView, mapView.camera);
expect(roll).to.be.closeTo(0, Number.EPSILON);
expect(roll).to.be.closeTo(0, EPS);
});
});
});
Expand Down

0 comments on commit da4ee0b

Please sign in to comment.