@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
255 lines • 9.61 kB
JavaScript
/* @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.
*/
var _a;
import { AnimationMixer, Box3, Object3D, Vector3 } from 'three';
import { $releaseFromCache, CachingGLTFLoader } from './CachingGLTFLoader.js';
import { moveChildren, reduceVertices } from './ModelUtils.js';
const $cancelPendingSourceChange = Symbol('cancelPendingSourceChange');
const $currentScene = Symbol('currentScene');
export const DEFAULT_FOV_DEG = 45;
/**
* An Object3D that can swap out its underlying
* model.
*/
export default class Model extends Object3D {
/**
* Creates a model.
*/
constructor() {
super();
this[_a] = null;
this.loader = new CachingGLTFLoader();
this.mixer = new AnimationMixer(null);
this.animations = [];
this.animationsByName = new Map();
this.currentAnimationAction = null;
this.modelContainer = new Object3D();
this.animationNames = [];
this.boundingBox = new Box3();
this.size = new Vector3();
this.idealCameraDistance = 0;
this.fieldOfViewAspect = 0;
this.userData = { url: null };
this.url = null;
this.name = 'Model';
this.modelContainer.name = 'ModelContainer';
this.add(this.modelContainer);
}
/**
* Returns a boolean indicating whether or not there is a
* loaded model attached.
*/
hasModel() {
return !!this.modelContainer.children.length;
}
applyEnvironmentMap(map) {
// 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) => {
// There are some cases where `obj.material` is
// an array of materials.
const mesh = obj;
if (Array.isArray(mesh.material)) {
for (let material of mesh.material) {
if (material.isMeshBasicMaterial) {
continue;
}
material.envMap = map;
material.needsUpdate = true;
}
}
else if (mesh.material && !mesh.material.isMeshBasicMaterial) {
mesh.material.envMap = map;
mesh.material.needsUpdate = true;
}
});
this.dispatchEvent({ type: 'envmap-change', value: map });
}
/**
* Pass in a THREE.Object3D to be controlled
* by this model.
*/
setObject(model) {
this.clear();
this.modelContainer.add(model);
this.updateFraming();
this.dispatchEvent({ type: 'model-load' });
}
async setSource(url, progressCallback) {
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 = null;
try {
scene = await new Promise(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;
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.updateFraming();
this.dispatchEvent({ type: 'model-load', url });
}
set animationTime(value) {
if (this.currentAnimationAction != null) {
this.currentAnimationAction.time = value;
}
}
get animationTime() {
if (this.currentAnimationAction != null) {
return this.currentAnimationAction.time;
}
return 0;
}
get hasActiveAnimation() {
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 = null, crossfadeTime = 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) {
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);
}
/**
* Calculates the idealCameraDistance and fieldOfViewAspect that allows the 3D
* object to be framed tightly in a 2D window of any aspect ratio without
* clipping at any camera orbit. The camera's center target point can be
* optionally specified. If no center is specified, it defaults to the center
* of the bounding box, which means asymmetric models will tend to be tight on
* one side instead of both. Proper choice of center can correct this.
*/
updateFraming(center = null) {
this.remove(this.modelContainer);
if (center == null) {
this.boundingBox.setFromObject(this.modelContainer);
this.boundingBox.getSize(this.size);
center = this.boundingBox.getCenter(new Vector3);
}
const radiusSquared = (value, vertex) => {
return Math.max(value, center.distanceToSquared(vertex));
};
const framedRadius = Math.sqrt(reduceVertices(this.modelContainer, radiusSquared));
const halfFov = (DEFAULT_FOV_DEG / 2) * Math.PI / 180;
this.idealCameraDistance = framedRadius / Math.sin(halfFov);
const verticalFov = Math.tan(halfFov);
const horizontalFov = (value, vertex) => {
vertex.sub(center);
const radiusXZ = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
return Math.max(value, radiusXZ / (this.idealCameraDistance - Math.abs(vertex.y)));
};
this.fieldOfViewAspect =
reduceVertices(this.modelContainer, horizontalFov) / verticalFov;
this.add(this.modelContainer);
}
}
_a = $currentScene;
//# sourceMappingURL=Model.js.map