UNPKG

@google/model-viewer

Version:

Easily display interactive 3D models on the web and in AR!

290 lines (233 loc) 10.1 kB
/* * 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, Matrix4, Plane, Ray, Vector3} from 'three'; import {IS_WEBXR_AR_CANDIDATE} from '../../constants.js'; import ModelViewerElementBase, {$renderer, $scene} from '../../model-viewer-base.js'; import {ARRenderer} from '../../three-components/ARRenderer.js'; import ModelScene from '../../three-components/ModelScene.js'; import Renderer from '../../three-components/Renderer.js'; import {assetPath, timePasses, waitForEvent} from '../helpers.js'; const expect = chai.expect; const applyPhoneRotation = (camera: Camera) => { // Rotate 180 degrees on Y (so it's not the default) // and angle 45 degrees towards the ground, like a phone. camera.matrix.identity() .makeRotationAxis(new Vector3(0, 1, 0), Math.PI) .multiply(new Matrix4().makeRotationAxis( new Vector3(1, 0, 0), -Math.PI / 4)); } class MockXRFrame implements XRFrame { constructor(public session: XRSession) { } // We don't use nor test the returned XRInputPose // other than its existence. getInputPose(_xrInputSource: XRInputSource, _frameOfRef?: XRReferenceSpace) { return {} as XRInputPose; } getViewerPose(_referenceSpace?: XRReferenceSpace): XRViewerPose { return {} as XRViewerPose } } customElements.define('model-viewer-element', ModelViewerElementBase); suite('ARRenderer', () => { let element: ModelViewerElementBase; let renderer: Renderer; let arRenderer: ARRenderer; let xrSession: XRSession; let inputSources: Array<XRInputSource> = []; const setInputSources = (sources: Array<XRInputSource>) => { inputSources = sources; }; const stubWebXrInterface = (arRenderer: ARRenderer) => { const xzPlane = new Plane(new Vector3(0, 1, 0)); const mat4 = new Matrix4(); const vec3 = new Vector3(); arRenderer.resolveARSession = async () => { class FakeSession extends EventTarget implements XRSession { public baseLayer: XRLayer = {} as XRLayer; requestFrameOfReference() { return {}; } async requestReferenceSpace(_options: XRReferenceSpaceOptions): Promise<XRReferenceSpace> { return { originOffset: { position: {} as DOMPointReadOnly, orientation: {} as DOMPointReadOnly, matrix: new Float32Array() } } as XRReferenceSpace; } getInputSources() { return inputSources; } /** * Returns a hit if ray collides with the XZ plane */ async requestHitTest( origin: Float32Array, dir: Float32Array, _frameOfRef: XRFrameOfReference): Promise<Array<XRHitResult>> { const hits = []; const ray = new Ray(new Vector3(...origin), new Vector3(...dir)); const success = ray.intersectPlane(xzPlane, vec3); if (success) { const hitMatrix = new Float32Array(16); (mat4.identity().setPosition(vec3) as any).toArray(hitMatrix); hits.push({hitMatrix}); } return hits; } requestAnimationFrame() { return 1; } cancelAnimationFrame() { } async end() { this.dispatchEvent(new CustomEvent('end')); } } xrSession = new FakeSession(); return xrSession; }; }; setup(() => { element = new ModelViewerElementBase(); renderer = element[$renderer]; arRenderer = ARRenderer.fromInlineRenderer(renderer); }); teardown(async () => { await arRenderer.stopPresenting().catch(() => {}); }); // NOTE(cdata): It will be a notable day when this test fails test('does not support presenting to AR on any browser', async () => { expect(await arRenderer.supportsPresentation()).to.be.equal(false); }); test('is not presenting if present has not been invoked', () => { expect(arRenderer.isPresenting).to.be.equal(false); }); suite('when presenting a scene', () => { let modelScene: ModelScene; if (!IS_WEBXR_AR_CANDIDATE) { return; } setup(async () => { element.src = assetPath('Astronaut.glb'); await waitForEvent(element, 'load'); modelScene = element[$scene]; stubWebXrInterface(arRenderer); setInputSources([]); }); test('presents the model at its natural scale', async () => { const model = modelScene.model; await arRenderer.present(modelScene); expect(model.scale.x).to.be.equal(1); expect(model.scale.y).to.be.equal(1); expect(model.scale.z).to.be.equal(1); }); suite('presentation ends', () => { test('restores the original model scale', async () => { const model = modelScene.model; const originalModelScale = model.scale.clone(); await arRenderer.present(modelScene); await arRenderer.stopPresenting(); expect(originalModelScale.x).to.be.equal(model.scale.x); expect(originalModelScale.y).to.be.equal(model.scale.y); expect(originalModelScale.z).to.be.equal(model.scale.z); }); }); suite('placing a model', () => { test('places the model oriented to the camera', async () => { const epsilon = 0.0001; const pivotRotation = 0.123; modelScene.pivot.rotation.y = pivotRotation; // Set camera to (10, 2, 0), rotated 180 degrees on Y (so // our dolly will need to rotate to face camera) and angled 45 // degrees towards the ground, like someone holding a phone. applyPhoneRotation(arRenderer.camera); arRenderer.camera.matrix.setPosition(new Vector3(10, 2, 0)); arRenderer.camera.updateMatrixWorld(true); await arRenderer.present(modelScene); await arRenderer.placeModel(); expect(arRenderer.dolly.position.x).to.be.equal(10); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(2); // Quaternion rotation results in the rotation towards the viewer // with -X and -Z, and the offset applied to Y to invert pivotRotation, // but it's inverted again here due to the -X/-Z rotation encoding expect(arRenderer.dolly.rotation.x).to.be.equal(-Math.PI); expect(arRenderer.dolly.rotation.y) .to.be.closeTo(pivotRotation, epsilon); expect(arRenderer.dolly.rotation.z).to.be.equal(-Math.PI); }); test('when a screen-type XRInputSource exists', async () => { await arRenderer.present(modelScene); expect(arRenderer.dolly.position.x).to.be.equal(0); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(0); // Set camera to (10, 2, 0), rotated 180 degrees on Y, // and angled 45 degrees towards the ground, like a phone. applyPhoneRotation(arRenderer.camera); arRenderer.camera.matrix.setPosition(new Vector3(10, 2, 0)); arRenderer.camera.updateMatrixWorld(true); setInputSources([{targetRayMode: 'screen', handedness: ''}]); arRenderer.processXRInput(new MockXRFrame(xrSession)); await waitForEvent(arRenderer, 'modelmove'); expect(arRenderer.dolly.position.x).to.be.equal(10); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(2); // Move the camera, ensure model hasn't changed arRenderer.camera.matrix.setPosition(new Vector3(0, 1, 0)); arRenderer.camera.updateMatrixWorld(true); setInputSources([]); arRenderer.processXRInput(new MockXRFrame(xrSession)); await timePasses(); expect(arRenderer.dolly.position.x).to.be.equal(10); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(2); }); test('ignores non-screen-type XRInputSources', async () => { applyPhoneRotation(arRenderer.camera); arRenderer.camera.updateMatrixWorld(true); await arRenderer.present(modelScene); setInputSources([{targetRayMode: 'gaze', handedness: ''}]); arRenderer.processXRInput(new MockXRFrame(xrSession)); await timePasses(); expect(arRenderer.dolly.position.x).to.be.equal(0); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(0); }); test('ignores when ray fails', async () => { applyPhoneRotation(arRenderer.camera); arRenderer.camera.matrix.setPosition(new Vector3(10, 2, 0)); arRenderer.camera.updateMatrixWorld(true); await arRenderer.present(modelScene); await arRenderer.placeModel(); expect(arRenderer.dolly.position.x).to.be.equal(10); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(2); // Now point phone upwards arRenderer.camera.matrix.identity().makeRotationAxis( new Vector3(1, 0, 0), Math.PI / 2); arRenderer.camera.matrix.setPosition(new Vector3(0, 2, 0)); arRenderer.camera.updateMatrixWorld(true); await arRenderer.placeModel(); expect(arRenderer.dolly.position.x).to.be.equal(10); expect(arRenderer.dolly.position.y).to.be.equal(0); expect(arRenderer.dolly.position.z).to.be.equal(2); }); }); }); });