@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
305 lines (254 loc) • 8.68 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 {AnimationAction, AnimationClip, AnimationMixer, Box3, Material, Mesh, MeshStandardMaterial, Object3D, Scene, Texture, Vector3} from 'three';
import {$releaseFromCache, CacheRetainedScene, CachingGLTFLoader} from './CachingGLTFLoader.js';
import {moveChildren} from './ModelUtils.js';
const $cancelPendingSourceChange = Symbol('cancelPendingSourceChange');
const $currentScene = Symbol('currentScene');
/**
* An Object3D that can swap out its underlying
* model.
*
* @extends THREE.Object3D
*/
export default class Model extends Object3D {
private[$currentScene]: CacheRetainedScene|null = null;
private loader = new CachingGLTFLoader();
private mixer: AnimationMixer = new AnimationMixer(null);
private[$cancelPendingSourceChange]: (() => void)|null;
private animations: Array<AnimationClip> = [];
private animationsByName: Map<string, AnimationClip> = new Map();
private currentAnimationAction: AnimationAction|null = null;
public modelContainer = new Object3D();
public animationNames: Array<string> = [];
public boundingBox = new Box3();
public size = new Vector3();
public userData: {url: string|null} = {url: null};
public url: string|null = null;
/**
* Creates a model.
*/
constructor() {
super();
this.name = 'Model';
this.modelContainer.name = 'ModelContainer';
this.add(this.modelContainer);
}
/**
* Returns a boolean indicating whether or not there is a
* loaded model attached.
*
* @return {Boolean}
*/
hasModel(): boolean {
return !!this.modelContainer.children.length;
}
applyEnvironmentMap(map: Texture|null) {
// Note that unlit models (using MeshBasicMaterial) should not apply
// an environment map, even though `map` is the currently configured
// environment map.
this.modelContainer.traverse((obj: Object3D) => {
// There are some cases where `obj.material` is
// an array of materials.
const mesh: Mesh = obj as Mesh;
if (Array.isArray(mesh.material)) {
for (let material of (mesh.material as Array<Material>)) {
if ((material as any).isMeshBasicMaterial) {
continue;
}
(material as MeshStandardMaterial).envMap = map;
material.needsUpdate = true;
}
} else if (mesh.material && !(mesh.material as any).isMeshBasicMaterial) {
(mesh.material as MeshStandardMaterial).envMap = map;
(mesh.material as Material).needsUpdate = true;
}
});
this.dispatchEvent({type: 'envmap-change', value: map});
}
setEnvironmentMapIntensity(intensity: number) {
const intensityIsNumber =
typeof intensity === 'number' && !(self as any).isNaN(intensity);
if (!intensityIsNumber) {
intensity = 1.0;
}
this.modelContainer.traverse(object => {
if (object && (object as Mesh).isMesh && (object as Mesh).material) {
const {material} = object as Mesh;
if (Array.isArray(material)) {
material.forEach(
material => (material as MeshStandardMaterial).envMapIntensity =
intensity);
} else {
((object as Mesh).material as MeshStandardMaterial).envMapIntensity =
intensity;
}
}
});
}
/**
* Pass in a THREE.Object3D to be controlled
* by this model.
*
* @param {THREE.Object3D}
*/
setObject(model: Object3D) {
this.clear();
this.modelContainer.add(model);
this.updateBoundingBox();
this.dispatchEvent({type: 'model-load'});
}
/**
* @param {String?} url
* @param {Function?} progressCallback
*/
async setSource(
url: string|null, progressCallback?: (progress: number) => void) {
if (!url || url === this.url) {
if (progressCallback) {
progressCallback(1);
}
return;
}
// If we have pending work due to a previous source change in progress,
// cancel it so that we do not incur a race condition:
if (this[$cancelPendingSourceChange] != null) {
this[$cancelPendingSourceChange]!();
this[$cancelPendingSourceChange] = null;
}
this.url = url;
let scene: Scene|null = null;
try {
scene = await new Promise<Scene|null>(async (resolve, reject) => {
this[$cancelPendingSourceChange] = () => reject();
try {
const result = await this.loader.load(url, progressCallback);
resolve(result);
} catch (error) {
reject(error);
}
});
} catch (error) {
if (error == null) {
return;
}
throw error;
}
this.clear();
this[$currentScene] = scene as CacheRetainedScene;
if (scene != null) {
moveChildren(scene, this.modelContainer);
}
this.modelContainer.traverse(obj => {
if (obj && obj.type === 'Mesh') {
obj.castShadow = true;
}
});
const animations = scene ? scene.userData.animations : [];
const animationsByName = new Map();
const animationNames = [];
for (const animation of animations) {
animationsByName.set(animation.name, animation);
animationNames.push(animation.name);
}
this.animations = animations;
this.animationsByName = animationsByName;
this.animationNames = animationNames;
this.userData.url = url;
this.updateBoundingBox();
this.dispatchEvent({type: 'model-load', url});
}
set animationTime(value: number) {
if (this.currentAnimationAction != null) {
this.currentAnimationAction.time = value;
}
}
get animationTime(): number {
if (this.currentAnimationAction != null) {
return this.currentAnimationAction.time;
}
return 0;
}
get hasActiveAnimation(): boolean {
return this.currentAnimationAction != null;
}
/**
* Plays an animation if there are any associated with the current model.
* Accepts an optional string name of an animation to play. If no name is
* provided, or if no animation is found by the given name, always falls back
* to playing the first animation.
*/
playAnimation(name: string|null = null, crossfadeTime: number = 0) {
const {animations} = this;
if (animations == null || animations.length === 0) {
console.warn(
`Cannot play animation (model does not have any animations)`);
return;
}
let animationClip = null;
if (name != null) {
animationClip = this.animationsByName.get(name);
}
if (animationClip == null) {
animationClip = animations[0];
}
try {
const {currentAnimationAction: lastAnimationAction} = this;
this.currentAnimationAction =
this.mixer.clipAction(animationClip, this).play();
this.currentAnimationAction.enabled = true;
if (lastAnimationAction != null &&
this.currentAnimationAction !== lastAnimationAction) {
this.currentAnimationAction.crossFadeFrom(
lastAnimationAction, crossfadeTime, false);
}
} catch (error) {
console.error(error);
}
}
stopAnimation() {
if (this.currentAnimationAction != null) {
this.currentAnimationAction.stop();
this.currentAnimationAction.reset();
this.currentAnimationAction = null;
}
this.mixer.stopAllAction();
}
updateAnimation(step: number) {
this.mixer.update(step);
}
clear() {
this.url = null;
this.userData = {url: null};
// Remove all current children
if (this[$currentScene] != null) {
moveChildren(this.modelContainer, this[$currentScene]!);
this[$currentScene]![$releaseFromCache]();
this[$currentScene] = null;
}
if (this.currentAnimationAction != null) {
this.currentAnimationAction.stop();
this.currentAnimationAction = null;
}
this.mixer.stopAllAction();
this.mixer.uncacheRoot(this);
}
updateBoundingBox() {
this.remove(this.modelContainer);
this.boundingBox.setFromObject(this.modelContainer);
this.boundingBox.getSize(this.size);
this.add(this.modelContainer);
}
}