Skip to content

Commit

Permalink
Combined Zoom (google#1076)
Browse files Browse the repository at this point in the history
* combined zoom works

* move constant parameters to top of file
  • Loading branch information
elalish authored Mar 20, 2020
1 parent 8fcef83 commit d3e79d7
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 68 deletions.
80 changes: 47 additions & 33 deletions packages/model-viewer/src/features/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,13 @@ import {Event, PerspectiveCamera, Spherical, Vector3} from 'three';
import {style} from '../decorators.js';
import ModelViewerElementBase, {$ariaLabel, $container, $loadedTime, $needsRender, $onModelLoad, $onResize, $scene, $tick, $userInputElement, Vector3D} from '../model-viewer-base.js';
import {degreesToRadians, normalizeUnit} from '../styles/conversions.js';
import {EvaluatedStyle, Intrinsics, SphericalIntrinsics, Vector3Intrinsics} from '../styles/evaluators.js';
import {EvaluatedStyle, Intrinsics, SphericalIntrinsics, StyleEvaluator, Vector3Intrinsics} from '../styles/evaluators.js';
import {IdentNode, NumberNode, numberNode, parseExpressions} from '../styles/parsers.js';
import {SAFE_RADIUS_RATIO} from '../three-components/Model.js';
import {ChangeEvent, ChangeSource, PointerChangeEvent, SmoothControls} from '../three-components/SmoothControls.js';
import {Constructor} from '../utilities.js';
import {timeline} from '../utilities/animation.js';



// NOTE(cdata): The following "animation" timing functions are deliberately
// being used in favor of CSS animations. In Safari 12.1 and 13, CSS animations
// would cause the interaction prompt to glitch unexpectedly
Expand All @@ -53,6 +52,19 @@ const fade = timeline(0, [
{frames: 4, value: 0}
]);

export const DEFAULT_CAMERA_ORBIT = '0deg 75deg 105%';
const DEFAULT_CAMERA_TARGET = 'auto auto auto';
const DEFAULT_FIELD_OF_VIEW = 'auto';

const MINIMUM_RADIUS_RATIO = 1.1 * SAFE_RADIUS_RATIO;

const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left'];
const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-'];

export const DEFAULT_INTERACTION_PROMPT_THRESHOLD = 3000;
export const INTERACTION_PROMPT =
'Use mouse, touch or arrow keys to control the camera!';

export interface CameraChangeDetails {
source: ChangeSource;
}
Expand Down Expand Up @@ -85,10 +97,6 @@ export const InteractionPolicy: {[index: string]: InteractionPolicy} = {
WHEN_FOCUSED: 'allow-when-focused'
};

export const DEFAULT_CAMERA_ORBIT = '0deg 75deg 105%';
const DEFAULT_CAMERA_TARGET = 'auto auto auto';
const DEFAULT_FIELD_OF_VIEW = 'auto';

export const fieldOfViewIntrinsics = (element: ModelViewerElementBase) => {
return {
basis: [numberNode(
Expand All @@ -98,7 +106,7 @@ export const fieldOfViewIntrinsics = (element: ModelViewerElementBase) => {
};

const minFieldOfViewIntrinsics = {
basis: [degreesToRadians(numberNode(10, 'deg')) as NumberNode<'rad'>],
basis: [degreesToRadians(numberNode(25, 'deg')) as NumberNode<'rad'>],
keywords: {auto: [null]}
};

Expand Down Expand Up @@ -129,22 +137,33 @@ export const cameraOrbitIntrinsics = (() => {
};
})();

const minCameraOrbitIntrinsics = {
basis: [
numberNode(-Infinity, 'rad'),
numberNode(Math.PI / 8, 'rad'),
numberNode(0, 'm')
],
keywords: {auto: [null, null, null]}
const minCameraOrbitIntrinsics = (element: ModelViewerElementBase) => {
const radius =
MINIMUM_RADIUS_RATIO * element[$scene].model.idealCameraDistance;

return {
basis: [
numberNode(-Infinity, 'rad'),
numberNode(Math.PI / 8, 'rad'),
numberNode(radius, 'm')
],
keywords: {auto: [null, null, null]}
};
};

const maxCameraOrbitIntrinsics = {
basis: [
numberNode(Infinity, 'rad'),
numberNode(Math.PI - Math.PI / 8, 'rad'),
numberNode(Infinity, 'm')
],
keywords: {auto: [null, null, null]}
const maxCameraOrbitIntrinsics = (element: ModelViewerElementBase) => {
const orbitIntrinsics = cameraOrbitIntrinsics(element);
const evaluator = new StyleEvaluator([], orbitIntrinsics);
const defaultRadius = evaluator.evaluate()[2];

return {
basis: [
numberNode(Infinity, 'rad'),
numberNode(Math.PI - Math.PI / 8, 'rad'),
numberNode(defaultRadius, 'm')
],
keywords: {auto: [null, null, null]}
};
};

export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
Expand All @@ -163,14 +182,7 @@ export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
const HALF_PI = Math.PI / 2.0;
const THIRD_PI = Math.PI / 3.0;
const QUARTER_PI = HALF_PI / 2.0;
const PHI = 2.0 * Math.PI;

const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left'];
const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-'];

export const DEFAULT_INTERACTION_PROMPT_THRESHOLD = 3000;
export const INTERACTION_PROMPT =
'Use mouse, touch or arrow keys to control the camera!';
const TAU = 2.0 * Math.PI;

export const $controls = Symbol('controls');
export const $promptElement = Symbol('promptElement');
Expand Down Expand Up @@ -530,7 +542,7 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
`translateX(${xOffset}px)`;
this[$promptAnimatedContainer].style.opacity = `${opacity}`;

this[$controls].adjustOrbit(deltaTheta, 0, 0, 0);
this[$controls].adjustOrbit(deltaTheta, 0, 0);

this[$lastPromptOffset] = offset;
this[$needsRender]();
Expand Down Expand Up @@ -587,9 +599,9 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
// Only change the aria-label if <model-viewer> is currently focused:
if (rootNode != null && rootNode.activeElement === this) {
const lastAzimuthalQuadrant =
(4 + Math.floor(((lastTheta % PHI) + QUARTER_PI) / HALF_PI)) % 4;
(4 + Math.floor(((lastTheta % TAU) + QUARTER_PI) / HALF_PI)) % 4;
const azimuthalQuadrant =
(4 + Math.floor(((theta % PHI) + QUARTER_PI) / HALF_PI)) % 4;
(4 + Math.floor(((theta % TAU) + QUARTER_PI) / HALF_PI)) % 4;

const lastPolarTrient = Math.floor(lastPhi / THIRD_PI);
const polarTrient = Math.floor(phi / THIRD_PI);
Expand Down Expand Up @@ -636,6 +648,8 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
this.requestUpdate('fieldOfView', this.fieldOfView);
this.requestUpdate('cameraOrbit', this.cameraOrbit);
this.requestUpdate('minCameraOrbit', this.minCameraOrbit);
this.requestUpdate('maxCameraOrbit', this.maxCameraOrbit);
this.requestUpdate('cameraTarget', this.cameraTarget);
this.jumpCameraToGoal();
}
Expand Down
10 changes: 5 additions & 5 deletions packages/model-viewer/src/test/features/controls-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ suite('ModelViewerElementBase with ControlsMixin', () => {
});

test('updates with current orbit after interaction', async () => {
controls.adjustOrbit(0, 0.5, 0, 0);
controls.adjustOrbit(0, 0.5, 0);
settleControls(controls);

const orbit = element.getCameraOrbit();
Expand Down Expand Up @@ -607,13 +607,13 @@ suite('ModelViewerElementBase with ControlsMixin', () => {
expect(input.getAttribute('aria-label'))
.to.be.equal('View from stage right');

controls.adjustOrbit(-Math.PI / 2.0, 0, 0, 0);
controls.adjustOrbit(-Math.PI / 2.0, 0, 0);
settleControls(controls);

expect(input.getAttribute('aria-label'))
.to.be.equal('View from stage back');

controls.adjustOrbit(Math.PI, 0, 0, 0);
controls.adjustOrbit(Math.PI, 0, 0);
settleControls(controls);

expect(input.getAttribute('aria-label'))
Expand All @@ -634,13 +634,13 @@ suite('ModelViewerElementBase with ControlsMixin', () => {
expect(input.getAttribute('aria-label'))
.to.be.equal('View from stage upper-front');

controls.adjustOrbit(0, -Math.PI / 2.0, 0, 0);
controls.adjustOrbit(0, -Math.PI / 2.0, 0);
settleControls(controls);

expect(input.getAttribute('aria-label'))
.to.be.equal('View from stage front');

controls.adjustOrbit(0, -Math.PI / 2.0, 0, 0);
controls.adjustOrbit(0, -Math.PI / 2.0, 0);
settleControls(controls);

expect(input.getAttribute('aria-label'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,13 @@ suite('SmoothControls', () => {
test(
'adjustOrbit does not move the goal theta more than pi past the current theta',
() => {
controls.adjustOrbit(-Math.PI * 3 / 2, 0, 0, 0);
controls.adjustOrbit(-Math.PI * 3 / 2, 0, 0);

controls.update(performance.now(), ONE_FRAME_DELTA);
const startingTheta = controls.getCameraSpherical().theta;
expect(startingTheta).to.be.greaterThan(0);

controls.adjustOrbit(-Math.PI * 3 / 2, 0, 0, 0);
controls.adjustOrbit(-Math.PI * 3 / 2, 0, 0);
settleControls(controls);
const goalTheta = controls.getCameraSpherical().theta;
expect(goalTheta).to.be.greaterThan(Math.PI);
Expand Down Expand Up @@ -502,7 +502,7 @@ suite('SmoothControls', () => {
suite('simultaneous user and imperative interaction', () => {
test('reports source as user interaction', async () => {
const eventDispatches = waitForEvent(controls, 'change');
controls.adjustOrbit(1, 1, 1, 1);
controls.adjustOrbit(1, 1, 1);
dispatchSyntheticEvent(element, 'keydown', {keyCode: KeyCode.UP});
settleControls(controls);

Expand Down
9 changes: 5 additions & 4 deletions packages/model-viewer/src/three-components/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const $cancelPendingSourceChange = Symbol('cancelPendingSourceChange');
const $currentGLTF = Symbol('currentGLTF');

export const DEFAULT_FOV_DEG = 45;
const DEFAULT_HALF_FOV = (DEFAULT_FOV_DEG / 2) * Math.PI / 180;
export const SAFE_RADIUS_RATIO = Math.sin(DEFAULT_HALF_FOV);
export const DEFAULT_TAN_FOV = Math.tan(DEFAULT_HALF_FOV);

const $loader = Symbol('loader');

Expand Down Expand Up @@ -267,9 +270,7 @@ export default class Model extends Object3D {
const framedRadius =
Math.sqrt(reduceVertices(this.modelContainer, radiusSquared));

const halfFov = (DEFAULT_FOV_DEG / 2) * Math.PI / 180;
this.idealCameraDistance = framedRadius / Math.sin(halfFov);
const verticalFov = Math.tan(halfFov);
this.idealCameraDistance = framedRadius / SAFE_RADIUS_RATIO;

const horizontalFov = (value: number, vertex: Vector3): number => {
vertex.sub(center!);
Expand All @@ -278,7 +279,7 @@ export default class Model extends Object3D {
value, radiusXZ / (this.idealCameraDistance - Math.abs(vertex.y)));
};
this.fieldOfViewAspect =
reduceVertices(this.modelContainer, horizontalFov) / verticalFov;
reduceVertices(this.modelContainer, horizontalFov) / DEFAULT_TAN_FOV;

this.add(this.modelContainer);
}
Expand Down
60 changes: 37 additions & 23 deletions packages/model-viewer/src/three-components/SmoothControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const $handleKey = Symbol('handleKey');
// Constants
const TOUCH_EVENT_RE = /^touch(start|end|move)$/;
const KEYBOARD_ORBIT_INCREMENT = Math.PI / 8;
const ZOOM_SENSITIVITY = 0.1;
const ZOOM_SENSITIVITY = 0.04;
const DECAY_MILLISECONDS = 50;
const NATURAL_FREQUENCY = 1 / DECAY_MILLISECONDS;
const NIL_SPEED = 0.0002 * NATURAL_FREQUENCY;
Expand Down Expand Up @@ -469,22 +469,38 @@ export class SmoothControls extends EventDispatcher {
* Adjust the orbital position of the camera relative to its current orbital
* position. Does not let the theta goal get more than pi ahead of the current
* theta, which ensures interpolation continues in the direction of the delta.
* The deltaZoom parameter adjusts both the field of view and the orbit radius
* such that they progress across their allowed ranges in sync.
*/
adjustOrbit(
deltaTheta: number, deltaPhi: number, deltaRadius: number,
deltaFov: number): boolean {
adjustOrbit(deltaTheta: number, deltaPhi: number, deltaZoom: number):
boolean {
const {theta, phi, radius} = this[$goalSpherical];
const {
minimumRadius,
maximumRadius,
minimumFieldOfView,
maximumFieldOfView
} = this[$options];

const dTheta = this[$spherical].theta - theta;
const dThetaLimit = Math.PI - 0.001;
const goalTheta =
theta - clamp(deltaTheta, -dThetaLimit - dTheta, dThetaLimit - dTheta);
const goalPhi = phi - deltaPhi;
const goalRadius = radius + deltaRadius;

const deltaRatio = deltaZoom === 0 ?
0 :
deltaZoom > 0 ? (maximumRadius! - radius) /
(Math.log(maximumFieldOfView!) - this[$goalLogFov]) :
(radius - minimumRadius!) /
(this[$goalLogFov] - Math.log(minimumFieldOfView!));

const goalRadius =
radius + (isFinite(deltaRatio) ? deltaZoom * deltaRatio : 0);
let handled = this.setOrbit(goalTheta, goalPhi, goalRadius);

if (deltaFov !== 0) {
const goalLogFov = this[$goalLogFov] + deltaFov;
if (deltaZoom !== 0) {
const goalLogFov = this[$goalLogFov] + deltaZoom;
this.setFieldOfView(Math.exp(goalLogFov));
handled = true;
}
Expand Down Expand Up @@ -586,10 +602,8 @@ export class SmoothControls extends EventDispatcher {
}

private[$userAdjustOrbit](
deltaTheta: number, deltaPhi: number, deltaRadius: number,
deltaFov: number): boolean {
const handled =
this.adjustOrbit(deltaTheta, deltaPhi, deltaRadius, deltaFov);
deltaTheta: number, deltaPhi: number, deltaZoom: number): boolean {
const handled = this.adjustOrbit(deltaTheta, deltaPhi, deltaZoom);

this[$isUserChange] = true;
// Always make sure that an initial event is triggered in case there is
Expand Down Expand Up @@ -640,10 +654,10 @@ export class SmoothControls extends EventDispatcher {
this[$lastTouches][0], this[$lastTouches][1]);
const touchDistance =
this[$twoTouchDistance](touches[0], touches[1]);
const deltaFov = -1 * ZOOM_SENSITIVITY *
(touchDistance - lastTouchDistance) / 10.0;
const deltaZoom =
ZOOM_SENSITIVITY * (lastTouchDistance - touchDistance) / 10.0;

handled = this[$userAdjustOrbit](0, 0, 0, deltaFov);
handled = this[$userAdjustOrbit](0, 0, deltaZoom);
}

break;
Expand Down Expand Up @@ -678,7 +692,7 @@ export class SmoothControls extends EventDispatcher {
this.dispatchEvent({type: 'pointer-change-start', pointer: {...pointer}});
}

return this[$userAdjustOrbit](deltaTheta, deltaPhi, 0, 0);
return this[$userAdjustOrbit](deltaTheta, deltaPhi, 0);
}

private[$handlePointerDown](event: MouseEvent|TouchEvent) {
Expand Down Expand Up @@ -728,9 +742,9 @@ export class SmoothControls extends EventDispatcher {
return;
}

const deltaFov = (event as WheelEvent).deltaY * ZOOM_SENSITIVITY / 30;
const deltaZoom = (event as WheelEvent).deltaY * ZOOM_SENSITIVITY / 30;

if ((this[$userAdjustOrbit](0, 0, 0, deltaFov) ||
if ((this[$userAdjustOrbit](0, 0, deltaZoom) ||
this[$options].eventHandlingBehavior === 'prevent-all') &&
event.cancelable) {
event.preventDefault();
Expand All @@ -747,27 +761,27 @@ export class SmoothControls extends EventDispatcher {
switch (event.keyCode) {
case KeyCode.PAGE_UP:
relevantKey = true;
handled = this[$userAdjustOrbit](0, 0, 0, ZOOM_SENSITIVITY);
handled = this[$userAdjustOrbit](0, 0, ZOOM_SENSITIVITY);
break;
case KeyCode.PAGE_DOWN:
relevantKey = true;
handled = this[$userAdjustOrbit](0, 0, 0, -1 * ZOOM_SENSITIVITY);
handled = this[$userAdjustOrbit](0, 0, -1 * ZOOM_SENSITIVITY);
break;
case KeyCode.UP:
relevantKey = true;
handled = this[$userAdjustOrbit](0, -KEYBOARD_ORBIT_INCREMENT, 0, 0);
handled = this[$userAdjustOrbit](0, -KEYBOARD_ORBIT_INCREMENT, 0);
break;
case KeyCode.DOWN:
relevantKey = true;
handled = this[$userAdjustOrbit](0, KEYBOARD_ORBIT_INCREMENT, 0, 0);
handled = this[$userAdjustOrbit](0, KEYBOARD_ORBIT_INCREMENT, 0);
break;
case KeyCode.LEFT:
relevantKey = true;
handled = this[$userAdjustOrbit](-KEYBOARD_ORBIT_INCREMENT, 0, 0, 0);
handled = this[$userAdjustOrbit](-KEYBOARD_ORBIT_INCREMENT, 0, 0);
break;
case KeyCode.RIGHT:
relevantKey = true;
handled = this[$userAdjustOrbit](KEYBOARD_ORBIT_INCREMENT, 0, 0, 0);
handled = this[$userAdjustOrbit](KEYBOARD_ORBIT_INCREMENT, 0, 0);
break;
}

Expand Down

0 comments on commit d3e79d7

Please sign in to comment.