@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
365 lines (281 loc) • 11.3 kB
text/typescript
/* @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 {expect} from 'chai';
import {$defaultPosterElement, $posterContainerElement} from '../../features/loading.js';
import {$scene, $userInputElement} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
import {timePasses, waitForEvent} from '../../utilities.js';
import {assetPath, pickShadowDescendant, rafPasses, until} from '../helpers.js';
const CUBE_GLB_PATH = assetPath('models/cube.gltf');
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
suite('Loading', () => {
let element: ModelViewerElement;
let firstChild: ChildNode|null;
setup(async () => {
element = new ModelViewerElement();
firstChild = document.body.firstChild;
document.body.insertBefore(element, firstChild);
// Wait at least a microtask for size calculations
await timePasses();
});
teardown(() => {
CachingGLTFLoader.clearCache();
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
});
suite('with a second element outside the viewport', () => {
let element2: ModelViewerElement;
setup(async () => {
element2 = new ModelViewerElement();
element2.loading = 'eager';
document.body.insertBefore(element2, firstChild);
element.style.height = '100vh';
element2.style.height = '100vh';
const load1 = waitForEvent(element, 'load');
const load2 = waitForEvent(element2, 'load');
element.src = CUBE_GLB_PATH;
element2.src = CUBE_GLB_PATH;
await Promise.all([load1, load2]);
});
teardown(() => {
if (element2.parentNode != null) {
element2.parentNode.removeChild(element2);
}
});
test('first element is visible', () => {
expect(element.modelIsVisible).to.be.true;
});
test('second element is not visible', () => {
expect(element2.modelIsVisible).to.be.false;
});
suite('scroll to second element', () => {
setup(() => {
element2.scrollIntoView();
});
test('first element is not visible', async () => {
await waitForEvent<CustomEvent>(
element,
'model-visibility',
event => event.detail.visible === false);
});
test('second element is visible', async () => {
await waitForEvent<CustomEvent>(
element2,
'model-visibility',
event => event.detail.visible === true);
});
});
});
test('creates a poster element that captures interactions', async () => {
const picked = pickShadowDescendant(element);
expect(picked).to.be.ok;
// TODO(cdata): Leaky internal details here:
expect(picked!.id).to.be.equal('default-poster');
});
test('does not load when hidden from render tree', async () => {
let loadDispatched = false;
const loadHandler = () => {
loadDispatched = true;
};
element.addEventListener('load', loadHandler);
element.style.display = 'none';
// Give IntersectionObserver a chance to notify. In Chrome, this takes
// two rAFs (empirically observed). Await extra time just in case:
await timePasses(100);
element.src = CUBE_GLB_PATH;
await timePasses(500); // Arbitrary time to allow model to load
element.removeEventListener('load', loadHandler);
expect(loadDispatched).to.be.false;
});
suite('load', () => {
suite('when a model src changes after loading', () => {
setup(async () => {
// The shadow is here to expose an earlier bug on unloading models.
element.shadowIntensity = 1;
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'poster-dismissed');
});
test('only dispatches load once per src change', async () => {
let loadCount = 0;
const onLoad = () => {
loadCount++;
};
element.addEventListener('load', onLoad);
try {
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
// Give any late-dispatching events a chance to dispatch
await timePasses(300);
expect(loadCount).to.be.equal(2);
} finally {
element.removeEventListener('load', onLoad);
}
});
test('getDimensions() returns correct size', () => {
const size = element.getDimensions();
expect(size.x).to.be.eq(1);
expect(size.y).to.be.eq(1);
expect(size.z).to.be.eq(1);
});
test('models are unloaded after src updates', async () => {
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
const {shadow, model, target} = element[$scene];
const {children} = target;
expect(children.length).to.be.eq(2, 'horse');
expect(children).to.contain(shadow, 'horse shadow');
expect(children).to.contain(model, 'horse model');
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const {children: children2} = target;
expect(children2.length).to.be.eq(2, 'cube');
expect(children2).to.contain(shadow, 'cube shadow');
expect(children2).to.contain(element[$scene].model, 'cube model');
});
test('generates 3DModel schema', async () => {
element.generateSchema = true;
await element.updateComplete;
const {schemaElement} = element[$scene];
expect(schemaElement.type).to.be.eq('application/ld+json');
expect(schemaElement.parentElement).to.be.eq(document.head);
const json = JSON.parse(schemaElement.textContent!);
const encoding = json.encoding[0];
expect(encoding.contentUrl).to.be.eq(CUBE_GLB_PATH);
expect(encoding.encodingFormat).to.be.eq('model/gltf+json');
element.generateSchema = false;
await element.updateComplete;
expect(schemaElement.parentElement).to.be.not.ok;
});
});
});
suite('loading', () => {
suite('src changes quickly', () => {
test('eventually notifies that current src is loaded', async () => {
element.loading = 'eager';
element.src = CUBE_GLB_PATH;
const loadCubeEvent =
waitForEvent(element, 'load') as Promise<CustomEvent>;
await timePasses();
element.src = HORSE_GLB_PATH;
const loadCube = await loadCubeEvent;
const loadHorse = await waitForEvent(element, 'load') as CustomEvent;
expect(loadCube.detail.url).to.be.eq(CUBE_GLB_PATH);
expect(loadHorse.detail.url).to.be.eq(HORSE_GLB_PATH);
});
});
suite('reveal', () => {
suite('auto', () => {
test('hides poster when element loads', async () => {
element.src = CUBE_GLB_PATH;
const input = element[$userInputElement];
expect(pickShadowDescendant(element))
.to.be.not.equal(
input, 'the poster should be shown until the model loads');
await waitForEvent(
element,
'model-visibility',
(event: any) => event.detail.visible);
await rafPasses();
expect(pickShadowDescendant(element)).to.be.equal(input);
element.reveal = 'manual';
await element.updateComplete;
await rafPasses();
expect(pickShadowDescendant(element))
.to.be.equal(input, 'changing reveal should not show the poster');
});
});
suite('manual', () => {
test('does not hide poster until dismissed', async () => {
element.loading = 'eager';
element.reveal = 'manual';
element.src = CUBE_GLB_PATH;
const posterElement = (element as any)[$defaultPosterElement];
const input = element[$userInputElement];
await waitForEvent(element, 'load');
posterElement.focus();
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
element.dismissPoster();
await until(() => {
return element.shadowRoot!.activeElement === input;
});
});
});
});
});
suite('configuring poster via attribute', () => {
suite('removing the attribute', () => {
test('sets poster to null', async () => {
// NOTE(cdata): This is less important after we resolve
// https://github.com/PolymerLabs/model-viewer/issues/76
element.setAttribute('poster', CUBE_GLB_PATH);
await timePasses();
element.removeAttribute('poster');
await timePasses();
expect(element.poster).to.be.equal(null);
});
});
});
suite('with loaded model src', () => {
setup(() => {
element.src = CUBE_GLB_PATH;
});
test('can be hidden imperatively', async () => {
const ostensiblyThePoster = pickShadowDescendant(element);
element.dismissPoster();
await waitForEvent<CustomEvent>(
element, 'model-visibility', event => event.detail.visible === true);
await rafPasses();
const ostensiblyNotThePoster = pickShadowDescendant(element);
expect(ostensiblyThePoster).to.not.be.equal(ostensiblyNotThePoster);
});
suite('when poster is hidden', () => {
setup(async () => {
element.dismissPoster();
await waitForEvent<CustomEvent>(
element,
'model-visibility',
event => event.detail.visible === true);
await rafPasses();
});
test('allows the input to be interactive', async () => {
const input = element[$userInputElement];
const picked = pickShadowDescendant(element);
expect(picked).to.be.equal(input);
});
test('when src is reset, poster is dismissible', async () => {
const posterElement = (element as any)[$defaultPosterElement];
const posterContainer = (element as any)[$posterContainerElement];
const inputElement = element[$userInputElement];
element.reveal = 'manual';
element.src = null;
element.showPoster();
await timePasses();
element.src = CUBE_GLB_PATH;
await timePasses();
expect(posterContainer.classList.contains('show')).to.be.true;
posterElement.focus();
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
element.dismissPoster();
await until(() => {
return element.shadowRoot!.activeElement === inputElement;
});
});
});
});
});