From d3e79d7005430ed1c124352e2fed3164a9505745 Mon Sep 17 00:00:00 2001 From: Emmett Lalish Date: Fri, 20 Mar 2020 15:48:44 -0700 Subject: [PATCH] Combined Zoom (#1076) * combined zoom works * move constant parameters to top of file --- .../model-viewer/src/features/controls.ts | 80 +++++++++++-------- .../src/test/features/controls-spec.ts | 10 +-- .../three-components/SmoothControls-spec.ts | 6 +- .../src/three-components/Model.ts | 9 ++- .../src/three-components/SmoothControls.ts | 60 ++++++++------ 5 files changed, 97 insertions(+), 68 deletions(-) diff --git a/packages/model-viewer/src/features/controls.ts b/packages/model-viewer/src/features/controls.ts index ce3678a091..6b7dbbcd91 100644 --- a/packages/model-viewer/src/features/controls.ts +++ b/packages/model-viewer/src/features/controls.ts @@ -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 @@ -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; } @@ -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( @@ -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]} }; @@ -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) => { @@ -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'); @@ -530,7 +542,7 @@ export const ControlsMixin = >( `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](); @@ -587,9 +599,9 @@ export const ControlsMixin = >( // Only change the aria-label if 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); @@ -636,6 +648,8 @@ export const ControlsMixin = >( 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(); } diff --git a/packages/model-viewer/src/test/features/controls-spec.ts b/packages/model-viewer/src/test/features/controls-spec.ts index 8d877e7d22..6fcac35eaf 100644 --- a/packages/model-viewer/src/test/features/controls-spec.ts +++ b/packages/model-viewer/src/test/features/controls-spec.ts @@ -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(); @@ -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')) @@ -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')) diff --git a/packages/model-viewer/src/test/three-components/SmoothControls-spec.ts b/packages/model-viewer/src/test/three-components/SmoothControls-spec.ts index 5bf9c9493f..170cd8e60b 100644 --- a/packages/model-viewer/src/test/three-components/SmoothControls-spec.ts +++ b/packages/model-viewer/src/test/three-components/SmoothControls-spec.ts @@ -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); @@ -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); diff --git a/packages/model-viewer/src/three-components/Model.ts b/packages/model-viewer/src/three-components/Model.ts index f4949a65cc..d6954d498b 100644 --- a/packages/model-viewer/src/three-components/Model.ts +++ b/packages/model-viewer/src/three-components/Model.ts @@ -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'); @@ -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!); @@ -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); } diff --git a/packages/model-viewer/src/three-components/SmoothControls.ts b/packages/model-viewer/src/three-components/SmoothControls.ts index 60ad0764e9..99d7e6cc59 100644 --- a/packages/model-viewer/src/three-components/SmoothControls.ts +++ b/packages/model-viewer/src/three-components/SmoothControls.ts @@ -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; @@ -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; } @@ -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 @@ -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; @@ -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) { @@ -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(); @@ -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; }