@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
328 lines (269 loc) • 9.61 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 {property} from 'lit/decorators.js';
import {LoopOnce, LoopPingPong, LoopRepeat} from 'three';
import ModelViewerElementBase, {$getModelIsVisible, $needsRender, $onModelLoad, $renderer, $scene, $tick} from '../model-viewer-base.js';
import {Constructor} from '../utilities.js';
const MILLISECONDS_PER_SECOND = 1000.0
const $changeAnimation = Symbol('changeAnimation');
const $appendAnimation = Symbol('appendAnimation');
const $detachAnimation = Symbol('detachAnimation');
const $paused = Symbol('paused');
interface PlayAnimationOptions {
repetitions: number;
pingpong: boolean;
modelIndex?: number;
}
interface AppendAnimationOptions {
pingpong?: boolean;
repetitions?: number|null;
weight?: number;
timeScale?: number;
fade?: boolean|number;
warp?: boolean|number;
relativeWarp?: boolean;
time?: number|null;
modelIndex?: number;
}
interface DetachAnimationOptions {
fade?: boolean|number;
modelIndex?: number;
}
const DEFAULT_PLAY_OPTIONS: PlayAnimationOptions = {
repetitions: Infinity,
pingpong: false
};
const DEFAULT_APPEND_OPTIONS: AppendAnimationOptions = {
pingpong: false,
repetitions: null,
weight: 1,
timeScale: 1,
fade: false,
warp: false,
relativeWarp: true,
time: null
};
const DEFAULT_DETACH_OPTIONS: DetachAnimationOptions = {
fade: true
};
export declare interface AnimationInterface {
autoplay: boolean;
animationName: string|void;
animationCrossfadeDuration: number;
readonly availableAnimations: Array<string>;
readonly appendedAnimations: Array<string>;
readonly paused: boolean;
readonly duration: number;
currentTime: number;
timeScale: number;
pause(): void;
play(options?: PlayAnimationOptions): void;
appendAnimation(animationName: string, options?: AppendAnimationOptions):
void;
detachAnimation(animationName: string, options?: DetachAnimationOptions):
void;
}
export const AnimationMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<AnimationInterface>&T => {
class AnimationModelViewerElement extends ModelViewerElement {
autoplay: boolean = false;
animationName: string|undefined = undefined;
animationCrossfadeDuration: number = 300;
protected[$paused]: boolean = true;
constructor(...args: any[]) {
super(args);
this[$scene].subscribeMixerEvent('loop', (e) => {
const count = e.action._loopCount;
const name = e.action._clip.name;
const uuid = e.action._clip.uuid;
const targetAnimation =
this[$scene].markedAnimations.find(e => e.name === name);
if (targetAnimation) {
this[$scene].updateAnimationLoop(
targetAnimation.name,
targetAnimation.loopMode,
targetAnimation.repetitionCount);
const filtredArr =
this[$scene].markedAnimations.filter(e => e.name !== name);
this[$scene].markedAnimations = filtredArr;
}
this.dispatchEvent(
new CustomEvent('loop', {detail: {count, name, uuid}}));
});
this[$scene].subscribeMixerEvent('finished', (e) => {
if (!this[$scene].appendedAnimations.includes(e.action._clip.name)) {
this[$paused] = true;
} else {
const filteredList = this[$scene].appendedAnimations.filter(
i => i !== e.action._clip.name);
this[$scene].appendedAnimations = filteredList;
}
this.dispatchEvent(new CustomEvent('finished'));
});
}
/**
* Returns an array
*/
get availableAnimations(): Array<string> {
if (this.loaded) {
return this[$scene].animationNames;
}
return [];
}
get duration(): number {
return this[$scene].duration;
}
get paused(): boolean {
return this[$scene].isAllAnimationsPaused();
}
get currentTime(): number {
return this[$scene].animationTime;
}
get appendedAnimations(): string[] {
return this[$scene].appendedAnimations;
}
set currentTime(value: number) {
this[$scene].animationTime = value;
this[$needsRender]();
}
get timeScale(): number {
return this[$scene].animationTimeScale;
}
set timeScale(value: number) {
this[$scene].animationTimeScale = value;
}
pause(options?: {modelIndex?: number}) {
if (options?.modelIndex == null && this.paused) {
return;
}
const modelIndex = options?.modelIndex ?? null;
this[$scene].pauseAnimation(modelIndex);
this.dispatchEvent(new CustomEvent('pause'));
}
play(options?: PlayAnimationOptions) {
if (this.availableAnimations.length > 0) {
const modelIndex = options?.modelIndex ?? null;
this[$scene].unpauseAnimation(modelIndex);
this[$changeAnimation](options);
this.dispatchEvent(new CustomEvent('play'));
}
}
appendAnimation(animationName: string, options?: AppendAnimationOptions) {
if (this.availableAnimations.length > 0) {
this[$paused] = false;
this[$scene].unpauseAnimation(options?.modelIndex ?? null);
this[$appendAnimation](animationName, options);
this.dispatchEvent(new CustomEvent('append-animation'));
}
}
detachAnimation(animationName: string, options?: DetachAnimationOptions) {
if (this.availableAnimations.length > 0) {
this[$paused] = false;
this[$scene].unpauseAnimation(options?.modelIndex ?? null);
this[$detachAnimation](animationName, options);
this.dispatchEvent(new CustomEvent('detach-animation'));
}
}
[$onModelLoad]() {
super[$onModelLoad]();
this[$scene].pauseAnimation();
if (this.animationName != null) {
this[$changeAnimation]();
}
if (this.autoplay) {
this.play();
}
}
[$tick](_time: number, delta: number) {
super[$tick](_time, delta);
if (this.paused ||
(!this[$getModelIsVisible]() && !this[$renderer].isPresenting)) {
return;
}
this[$scene].updateAnimation(delta / MILLISECONDS_PER_SECOND);
this[$needsRender]();
}
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('autoplay') && this.autoplay) {
this.play();
}
if (changedProperties.has('animationName')) {
this[$changeAnimation]();
}
}
[$changeAnimation](options: PlayAnimationOptions = DEFAULT_PLAY_OPTIONS) {
const repetitions = options.repetitions ?? Infinity;
const mode = options.pingpong ?
LoopPingPong :
(repetitions === 1 ? LoopOnce : LoopRepeat);
this[$scene].playAnimation(
this.animationName,
this.animationCrossfadeDuration / MILLISECONDS_PER_SECOND,
mode,
repetitions,
options.modelIndex);
// If we are currently paused, we need to force a render so that
// the scene updates to the first frame of the new animation
if (this[$paused]) {
this[$scene].updateAnimation(0);
this[$needsRender]();
}
}
[$appendAnimation](
animationName: string = '', options: AppendAnimationOptions = {}) {
const opts = {...DEFAULT_APPEND_OPTIONS, ...options};
const repetitions = opts.repetitions ?? Infinity;
const mode = opts.pingpong ? LoopPingPong :
(repetitions === 1 ? LoopOnce : LoopRepeat);
const needsToStop = !!options.repetitions || 'pingpong' in options;
this[$scene].appendAnimation(
animationName ? animationName : this.animationName,
mode,
repetitions,
opts.weight,
opts.timeScale,
opts.fade,
opts.warp,
opts.relativeWarp,
opts.time,
needsToStop,
opts.modelIndex);
// If we are currently paused, we need to force a render so that
// the scene updates to the first frame of the new animation
if (this[$paused]) {
this[$scene].updateAnimation(0);
this[$needsRender]();
}
}
[$detachAnimation](
animationName: string = '', options: DetachAnimationOptions = {}) {
const opts = {...DEFAULT_DETACH_OPTIONS, ...options};
this[$scene].detachAnimation(
animationName ? animationName : this.animationName,
opts.fade,
opts.modelIndex);
// If we are currently paused, we need to force a render so that
// the scene updates to the first frame of the new animation
if (this[$paused]) {
this[$scene].updateAnimation(0);
this[$needsRender]();
}
}
}
return AnimationModelViewerElement;
};