UNPKG

@google/model-viewer

Version:

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

305 lines (254 loc) 8.68 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 {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); } }