@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
436 lines (330 loc) • 13.9 kB
text/typescript
/*
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Camera, PerspectiveCamera, Vector3} from 'three';
import {Damper, DEFAULT_OPTIONS, KeyCode, SmoothControls} from '../../three-components/SmoothControls.js';
import {step} from '../../utilities.js';
import {dispatchSyntheticEvent} from '../helpers.js';
const expect = chai.expect;
const ONE_FRAME_DELTA = 1000.0 / 60.0;
const FIFTY_FRAME_DELTA = 50.0 * ONE_FRAME_DELTA;
const HALF_PI = Math.PI / 2.0;
const QUARTER_PI = HALF_PI / 2.0;
const THREE_QUARTERS_PI = HALF_PI + QUARTER_PI;
const USER_INTERACTION_CHANGE_SOURCE = 'user-interaction';
const DEFAULT_INTERACTION_CHANGE_SOURCE = 'none';
// NOTE(cdata): Precision is a bit off when comparing e.g., expected camera
// direction in practice:
const FLOAT_EQUALITY_THRESHOLD = 1e-6;
/**
* Returns true if the camera is looking at a given position, within +/-
* FLOAT_EQUALITY_THRESHOLD on each axis.
*/
const cameraIsLookingAt = (camera: Camera, position: Vector3) => {
const cameraDirection = camera.getWorldDirection(new Vector3());
const expectedDirection = position.clone().sub(camera.position).normalize();
const deltaX = Math.abs(cameraDirection.x - expectedDirection.x);
const deltaY = Math.abs(cameraDirection.y - expectedDirection.y);
const deltaZ = Math.abs(cameraDirection.z - expectedDirection.z);
return step(FLOAT_EQUALITY_THRESHOLD, deltaX) === 0 &&
step(FLOAT_EQUALITY_THRESHOLD, deltaY) === 0 &&
step(FLOAT_EQUALITY_THRESHOLD, deltaZ) === 0;
};
/**
* Settle controls by performing 50 frames worth of updates
*/
export const settleControls = (controls: SmoothControls) =>
controls.update(performance.now(), FIFTY_FRAME_DELTA);
suite('Damper', () => {
let damper: Damper;
const initial = 5;
const goal = 2;
setup(() => {
damper = new Damper();
});
test('converges to goal with large time step without overshoot', () => {
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, initial);
expect(final).to.be.eql(goal);
});
test('stays at initial value for negative time step', () => {
const final = damper.update(initial, goal, -1 * FIFTY_FRAME_DELTA, initial);
expect(final).to.be.eql(initial);
});
});
suite('SmoothControls', () => {
let controls: SmoothControls;
let camera: PerspectiveCamera;
let element: HTMLDivElement;
setup(() => {
element = document.createElement<'div'>('div');
camera = new PerspectiveCamera();
controls = new SmoothControls(camera, element);
element.style.height = '100px';
element.tabIndex = 0;
document.body.appendChild(element);
controls.enableInteraction();
});
teardown(() => {
document.body.removeChild(element);
controls.disableInteraction();
});
suite('when updated', () => {
test('repositions the camera within the configured radius options', () => {
settleControls(controls);
const radius = camera.position.length();
expect(radius).to.be.within(
DEFAULT_OPTIONS.minimumRadius as number,
DEFAULT_OPTIONS.maximumRadius as number);
});
test('causes the camera to look at the target', () => {
settleControls(controls);
expect(cameraIsLookingAt(camera, controls.getTarget())).to.be.equal(true);
});
suite('when target is modified', () => {
test('camera looks at the configured target', () => {
controls.setTarget(new Vector3(3, 2, 1));
settleControls(controls);
expect(cameraIsLookingAt(camera, controls.getTarget()))
.to.be.equal(true);
});
});
suite('when orbit is changed', () => {
suite('radius', () => {
test('changes the absolute distance to the target', () => {
settleControls(controls);
expect(camera.position.length())
.to.be.equal(DEFAULT_OPTIONS.minimumRadius);
controls.setOrbit(0, HALF_PI, 1.5);
settleControls(controls);
expect(camera.position.length()).to.be.equal(1.5);
});
});
});
suite('keyboard input', () => {
let initialCameraPosition: Vector3;
setup(() => {
settleControls(controls);
initialCameraPosition = camera.position.clone();
});
suite('global keyboard input', () => {
test('does not change orbital position of camera', () => {
dispatchSyntheticEvent(window, 'keydown', {keyCode: KeyCode.UP});
settleControls(controls);
expect(camera.position.z).to.be.equal(initialCameraPosition.z);
});
});
suite('local keyboard input', () => {
test('changes orbital position of camera', () => {
element.focus();
dispatchSyntheticEvent(element, 'keydown', {keyCode: KeyCode.UP});
settleControls(controls);
expect(camera.position.z).to.not.be.equal(initialCameraPosition.z);
});
});
});
suite('customizing options', () => {
suite('azimuth', () => {
setup(() => {
controls.applyOptions({
minimumAzimuthalAngle: -1 * HALF_PI,
maximumAzimuthalAngle: HALF_PI
});
});
test('prevents camera azimuth from exceeding options', () => {
controls.setOrbit(-Math.PI, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().theta).to.be.equal(-1 * HALF_PI);
controls.setOrbit(Math.PI, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().theta).to.be.equal(HALF_PI);
});
});
suite('pole', () => {
setup(() => {
controls.applyOptions({
minimumPolarAngle: QUARTER_PI,
maximumPolarAngle: THREE_QUARTERS_PI
});
});
test('prevents camera polar angle from exceeding options', () => {
controls.setOrbit(0, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().phi).to.be.equal(QUARTER_PI);
controls.setOrbit(0, Math.PI, 0);
settleControls(controls);
expect(controls.getCameraSpherical().phi)
.to.be.equal(THREE_QUARTERS_PI);
});
});
suite('radius', () => {
setup(() => {
controls.applyOptions({minimumRadius: 10, maximumRadius: 20});
});
test('prevents camera distance from exceeding options', () => {
controls.setOrbit(0, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().radius).to.be.equal(10);
controls.setOrbit(0, 0, 100);
settleControls(controls);
expect(controls.getCameraSpherical().radius).to.be.equal(20);
});
});
suite('event handling', () => {
suite('prevent-all', () => {
setup(() => {
controls.applyOptions({
eventHandlingBehavior: 'prevent-all',
interactionPolicy: 'always-allow'
});
});
test('always preventDefaults handled, cancellable UI events', () => {
dispatchSyntheticEvent(element, 'mousedown');
const mousemove = dispatchSyntheticEvent(element, 'mousemove');
expect(mousemove.defaultPrevented).to.be.equal(true);
});
});
suite('prevent-handled', () => {
setup(() => {
controls.applyOptions({eventHandlingBehavior: 'prevent-handled'});
});
test('does not cancel unhandled UI events', () => {
dispatchSyntheticEvent(element, 'mousedown');
const mousemove = dispatchSyntheticEvent(element, 'mousemove');
expect(mousemove.defaultPrevented).to.be.equal(false);
});
});
});
suite('interaction policy', () => {
suite('allow-when-focused', () => {
setup(() => {
controls.applyOptions({interactionPolicy: 'allow-when-focused'});
settleControls(controls);
});
test('does not zoom when scrolling while blurred', () => {
expect(controls.getCameraSpherical().radius)
.to.be.equal(DEFAULT_OPTIONS.minimumRadius);
dispatchSyntheticEvent(element, 'wheel');
settleControls(controls);
expect(controls.getCameraSpherical().radius)
.to.be.equal(DEFAULT_OPTIONS.minimumRadius);
});
test('does not orbit when pointing while blurred', () => {
const originalPhi = controls.getCameraSpherical().phi;
dispatchSyntheticEvent(
element, 'mousedown', {clientX: 0, clientY: 10});
dispatchSyntheticEvent(
element, 'mousemove', {clientX: 0, clientY: 0});
expect(controls.getCameraSpherical().phi).to.be.equal(originalPhi);
});
test('does zoom when scrolling while focused', () => {
expect(controls.getCameraSpherical().radius)
.to.be.equal(DEFAULT_OPTIONS.minimumRadius);
element.focus();
dispatchSyntheticEvent(element, 'wheel');
settleControls(controls);
expect(controls.getCameraSpherical().radius)
.to.be.greaterThan(DEFAULT_OPTIONS.minimumRadius as number);
});
});
suite('always-allow', () => {
setup(() => {
controls.applyOptions({interactionPolicy: 'always-allow'});
settleControls(controls);
});
test('orbits when pointing, even while blurred', () => {
const originalPhi = controls.getCameraSpherical().phi;
dispatchSyntheticEvent(
element, 'mousedown', {clientX: 0, clientY: 10});
dispatchSyntheticEvent(
element, 'mousemove', {clientX: 0, clientY: 0});
settleControls(controls);
expect(controls.getCameraSpherical().phi)
.to.be.greaterThan(originalPhi);
});
test('zooms when scrolling, even while blurred', () => {
expect(controls.getCameraSpherical().radius)
.to.be.equal(DEFAULT_OPTIONS.minimumRadius);
dispatchSyntheticEvent(element, 'wheel');
settleControls(controls);
expect(controls.getCameraSpherical().radius)
.to.be.greaterThan(DEFAULT_OPTIONS.minimumRadius as number);
});
});
suite('events', () => {
test('dispatches "change" on user interaction', () => {
let didCall = false;
let changeSource;
controls.addEventListener('change', ({source}) => {
didCall = true;
changeSource = source;
});
dispatchSyntheticEvent(element, 'keydown', {keyCode: KeyCode.UP});
settleControls(controls);
expect(didCall).to.be.true;
expect(changeSource).to.equal(USER_INTERACTION_CHANGE_SOURCE);
});
test('dispatches "change" on direct orbit change', () => {
let didCall = false;
let changeSource;
controls.addEventListener('change', ({source}) => {
didCall = true;
changeSource = source;
});
controls.setOrbit(33, 33, 33);
settleControls(controls);
expect(didCall).to.be.true;
expect(changeSource).to.equal(DEFAULT_INTERACTION_CHANGE_SOURCE);
});
test('sends "user-interaction" multiple times', () => {
const expectedSources = [
USER_INTERACTION_CHANGE_SOURCE,
USER_INTERACTION_CHANGE_SOURCE,
USER_INTERACTION_CHANGE_SOURCE,
];
let changeSource: Array<string> = [];
controls.addEventListener('change', ({source}) => {
changeSource.push(source);
});
dispatchSyntheticEvent(element, 'keydown', {keyCode: KeyCode.UP});
controls.update(performance.now(), ONE_FRAME_DELTA);
controls.update(performance.now(), ONE_FRAME_DELTA);
controls.update(performance.now(), ONE_FRAME_DELTA);
expect(changeSource.length).to.equal(3);
expect(changeSource).to.eql(expectedSources);
});
test('does not send "user-interaction" after setOrbit', () => {
const expectedSources = [
USER_INTERACTION_CHANGE_SOURCE,
USER_INTERACTION_CHANGE_SOURCE,
DEFAULT_INTERACTION_CHANGE_SOURCE,
DEFAULT_INTERACTION_CHANGE_SOURCE,
];
let changeSource: Array<string> = [];
controls.addEventListener('change', ({source}) => {
changeSource.push(source);
});
dispatchSyntheticEvent(element, 'keydown', {keyCode: KeyCode.UP});
controls.update(performance.now(), ONE_FRAME_DELTA);
controls.update(performance.now(), ONE_FRAME_DELTA);
controls.setOrbit(3, 3, 3);
controls.update(performance.now(), ONE_FRAME_DELTA);
controls.update(performance.now(), ONE_FRAME_DELTA);
expect(changeSource.length).to.equal(4);
expect(changeSource).to.eql(expectedSources);
});
});
});
});
});
});