UNPKG

@google/model-viewer

Version:

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

290 lines (232 loc) 8.85 kB
/* @license * Copyright 2019 Google LLC. 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 {Matrix4, PerspectiveCamera, Vector2, Vector3} from 'three'; import ModelViewerElementBase, {$canvas, $renderer} from '../../model-viewer-base.js'; import {ARRenderer} from '../../three-components/ARRenderer.js'; import {SETTLING_TIME} from '../../three-components/Damper.js'; import {ModelScene} from '../../three-components/ModelScene.js'; import {assetPath} from '../helpers.js'; const expect = chai.expect; class MockXRFrame implements XRFrame { constructor(public session: XRSession) { } // We don't use nor test the returned XRPose other than its existence. getPose(_xrSpace: XRSpace, _frameOfRef: XRReferenceSpace) { return {} as XRPose; } getViewerPose(_referenceSpace?: XRReferenceSpace): XRViewerPose { // Rotate 180 degrees on Y (so it's not the default) // and angle 45 degrees towards the ground, like a phone. const matrix = new Matrix4() .identity() .makeRotationAxis(new Vector3(0, 1, 0), Math.PI) .multiply(new Matrix4().makeRotationAxis( new Vector3(1, 0, 0), -Math.PI / 4)); matrix.setPosition(10, 2, 3); const transform: XRRigidTransform = { matrix: matrix.elements as unknown as Float32Array, position: {} as DOMPointReadOnly, orientation: {} as DOMPointReadOnly, inverse: {} as XRRigidTransform }; const camera = new PerspectiveCamera(); const view: XRView = { eye: {} as XREye, projectionMatrix: camera.projectionMatrix.elements as unknown as Float32Array, viewMatrix: {} as Float32Array, transform: transform, recommendedViewportScale: null, requestViewportScale: (_scale: number|null) => {} }; const viewerPos: XRViewerPose = {transform: transform, views: [view]}; return viewerPos; } getHitTestResults(_xrHitTestSource: XRHitTestSource) { return []; } getHitTestResultsForTransientInput(_hitTestSource: XRTransientInputHitTestSource) { return []; } } suite('ARRenderer', () => { let nextId = 0; let tagName: string; let ModelViewerElement: Constructor<ModelViewerElementBase>; let element: ModelViewerElementBase; let arRenderer: ARRenderer; let xrSession: XRSession; let inputSources: Array<XRInputSource> = []; const setInputSources = (sources: Array<XRInputSource>) => { inputSources = sources; }; const stubWebXrInterface = (arRenderer: ARRenderer) => { arRenderer.resolveARSession = async () => { class FakeSession extends EventTarget implements XRSession { public renderState: XRRenderState = { baseLayer: { getViewport: () => { return {x: 0, y: 0, width: 320, height: 240} as XRViewport } } as XRLayer } as XRRenderState; public hitTestSources: Set<XRHitTestSource> = new Set<XRHitTestSource>(); updateRenderState(_object: any) { } requestFrameOfReference() { return {}; } async requestReferenceSpace(_type: XRReferenceSpaceType): Promise<XRReferenceSpace> { return {} as XRReferenceSpace; } get inputSources(): Array<XRInputSource> { return inputSources; } async requestHitTestSource(_options: XRHitTestOptionsInit): Promise<XRHitTestSource> { const result = {cancel: () => {}}; this.hitTestSources.add(result); return result; } async requestHitTestSourceForTransientInput( _options: XRTransientInputHitTestOptionsInit) { const result = {cancel: () => {}}; this.hitTestSources.add(result); return result; } requestAnimationFrame() { return 1; } cancelAnimationFrame() { } async end() { this.dispatchEvent(new CustomEvent('end')); } } xrSession = new FakeSession(); return xrSession; }; }; setup(() => { tagName = `model-viewer-arrenderer-${nextId++}`; ModelViewerElement = class extends ModelViewerElementBase { static get is() { return tagName; } }; customElements.define(tagName, ModelViewerElement); element = new ModelViewerElement(); arRenderer = new ARRenderer(element[$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; let oldXRRay: any; setup(async () => { modelScene = new ModelScene({ element: element, canvas: element[$canvas], width: 200, height: 100, }); await modelScene.setSource(assetPath('models/Astronaut.glb')); stubWebXrInterface(arRenderer); setInputSources([]); oldXRRay = (window as any).XRRay; (window as any).XRRay = class MockXRRay implements XRRay { readonly origin = new DOMPointReadOnly; readonly direction = new DOMPointReadOnly; matrix = new Float32Array; constructor(_origin: DOMPointInit, _direction: DOMPointInit) { } } await arRenderer.present(modelScene); }); teardown(() => { (window as any).XRRay = oldXRRay; }); test('presents the model at its natural scale', () => { const scale = modelScene.target.getWorldScale(new Vector3()); expect(scale.x).to.be.equal(1); expect(scale.y).to.be.equal(1); expect(scale.z).to.be.equal(1); }); suite('presentation ends', () => { setup(async () => { await arRenderer.stopPresenting(); }); test('restores the model to its natural scale', () => { const scale = modelScene.target.getWorldScale(new Vector3()); expect(scale.x).to.be.equal(1); expect(scale.y).to.be.equal(1); expect(scale.z).to.be.equal(1); }); test('restores original camera', () => { expect(modelScene.getCamera()).to.be.equal(modelScene.camera); }); test('restores scene size', () => { expect(modelScene.width).to.be.equal(200); expect(modelScene.height).to.be.equal(100); }); }); // We're going to need to mock out XRFrame more so it can set the camera // in order to properly test this. suite('after initial placement', () => { let yaw: number; setup(async () => { await arRenderer.present(modelScene); arRenderer.onWebXRFrame(0, new MockXRFrame(arRenderer.currentSession!)); yaw = modelScene.yaw; }); test('places the model oriented to the camera', () => { const epsilon = 0.0001; const {target, position} = modelScene; const cameraPosition = arRenderer.camera.position; const cameraToHit = new Vector2( position.x - cameraPosition.x, position.z - cameraPosition.z); const forward = target.getWorldDirection(new Vector3()); const forwardProjection = new Vector2(forward.x, forward.z); expect(forward.y).to.be.equal(0); expect(cameraToHit.cross(forwardProjection)).to.be.closeTo(0, epsilon); expect(cameraToHit.dot(forwardProjection)).to.be.lessThan(0); }); suite('after hit placement', () => { let hitPosition: Vector3; setup(async () => { hitPosition = new Vector3(5, -1, 1); await arRenderer.placeModel(hitPosition); // Long enough time to settle at new position. arRenderer.onWebXRFrame( SETTLING_TIME, new MockXRFrame(arRenderer.currentSession!)); }); test('scene has the same orientation', () => { expect(modelScene.yaw).to.be.equal(yaw); }); }); }); }); });