UNPKG

threepipe

Version:

A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.

564 lines 24.6 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var GLTFAnimationPlugin_1; import { AViewerPluginSync } from '../../viewer'; import { absMax, now, onChange, onChange2, PointerDragHelper, serialize } from 'ts-browser-helpers'; import { uiButton, uiDropdown, uiFolderContainer, uiMonitor, uiSlider, uiToggle } from 'uiconfig.js'; import { AnimationMixer, LoopOnce, LoopRepeat } from 'three'; import { generateUUID } from '../../three'; /** * Manages playback of GLTF animations. * * The GLTF animations can be created in any 3d software that supports GLTF export like Blender. * If animations from multiple files are loaded, they will be merged in a single root object and played together. * * The time playback is managed automatically, but can be controlled manually by setting {@link autoIncrementTime} to false and using {@link setTime} to set the time. * * This plugin is made for playing, pausing, stopping, all the animations at once, while it is possible to play individual animations, it is not recommended. * * To play individual animations, with custom choreography, use the {@link GLTFAnimationPlugin.animations} property to get reference to the animation clips and actions. Create your own mixers and control the animation playback like in three.js * * @category Plugins */ let GLTFAnimationPlugin = GLTFAnimationPlugin_1 = class GLTFAnimationPlugin extends AViewerPluginSync { /** * Get the current state of the animation. (read only) * use {@link playAnimation}, {@link pauseAnimation}, {@link stopAnimation} to change the state. */ get animationState() { return this._animationState; } /** * Get the current animation time. (read only) * The time is managed automatically. * To manage the time manually set {@link autoIncrementTime} to false and use {@link setTime} to change the time. */ get animationTime() { return this._animationTime; } /** * Get the current animation duration (max of all animations). (read only) */ get animationDuration() { return this._animationDuration; } playPauseAnimation() { this._animationState === 'playing' ? this.pauseAnimation() : this.playAnimation(); } constructor() { super(); this.enabled = true; /** * List of GLTF animations loaded with the models. * The animations are standard threejs AnimationClip and their AnimationAction. Each set of actions also has a mixer. */ this.animations = []; /** * If true, the animation time will be automatically incremented by the time delta, otherwise it has to be set manually between 0 and the animationDuration using `setTime`. (default: true) */ this.autoIncrementTime = true; /** * Loop the complete animation. (not individual actions) * This happens {@link loopRepetitions} times. */ this.loopAnimations = true; /** * Number of times to loop the animation. (not individual actions) * Only applicable when {@link loopAnimations} is true. */ this.loopRepetitions = Infinity; /** * Timescale for the animation. (not individual actions) * If set to 0, it will be ignored. */ this.timeScale = 1; /** * Speed of the animation. (not individual actions) * This can be set to 0. */ this.animationSpeed = 1; /** * Automatically track mouse wheel events to seek animations * Control damping/smoothness with {@link scrollAnimationDamping} * See also {@link animateOnPageScroll}. {@link animateOnDrag} */ this.animateOnScroll = false; /** * Damping for the scroll animation, when {@link animateOnScroll} is true. */ this.scrollAnimationDamping = 0.1; /** * Automatically track scroll event in window and use `window.scrollY` along with {@link pageScrollHeight} to seek animations * Control damping/smoothness with {@link pageScrollAnimationDamping} * See also {@link animateOnDrag}, {@link animateOnScroll} */ this.animateOnPageScroll = false; /** * Damping for the scroll animation, when {@link animateOnPageScroll} is true. */ this.pageScrollAnimationDamping = 0.1; /** * Automatically track drag events in either x or y axes to seek animations * Control axis with {@link dragAxis} and damping/smoothness with {@link dragAnimationDamping} */ this.animateOnDrag = false; /** * Axis to track for drag events, when {@link animateOnDrag} is true. * `x` will track horizontal drag, `y` will track vertical drag. */ this.dragAxis = 'y'; /** * Damping for the drag animation, when {@link animateOnDrag} is true. */ this.dragAnimationDamping = 0.3; /** * If true, the animation will be played automatically when the model(any model with animations) is loaded. */ this.autoplayOnLoad = false; /** * Sync the duration of all clips based on the max duration, helpful for things like timeline markers */ this.syncMaxDuration = false; this._animationState = 'none'; this._lastAnimationTime = 0; this._animationTime = 0; this._animationDuration = 0; this._scrollAnimationState = 0; this._pageScrollAnimationState = 0; this._dragAnimationState = 0; this._pointerDragHelper = new PointerDragHelper(); this._lastFrameTime = 0; this._fadeDisabled = false; this._lastAnimId = ''; this._objectAdded = (ev) => { const object = ev.object; if (!this._viewer) return; let changed = false; object.traverse((obj) => { if (!this._viewer) return; const clips = obj.animations; if (clips.length < 1) return; const duration = Math.max(...clips.map(an => an.duration)); if (object.userData.gltfAnim_SyncMaxDuration ?? this.syncMaxDuration) { clips.forEach(cp => cp.duration = duration); object.userData.gltfAnim_SyncMaxDuration = true; } // todo: check why do we need to do this? wont this create problems with looping or is it for that so that looping works in sync. const mixer = new AnimationMixer(this._viewer.scene.modelRoot); // add to modelRoot so it works with GLTF export... const actions = clips.map(an => mixer.clipAction(an).setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions)); actions.forEach(ac => ac.clampWhenFinished = true); this.animations.push({ mixer, clips, actions, duration, }); // todo remove on object dispose changed = true; }); // this.playAnimation() if (changed) { this._onPropertyChange(!this.autoplayOnLoad); if (this.autoplayOnLoad) this.playAnimation(); } return; }; this.pageScrollHeight = () => Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight) - window.innerHeight; this.playClips = this.playClips.bind(this); this.playClip = this.playClip.bind(this); this.playAnimation = this.playAnimation.bind(this); this.playPauseAnimation = this.playPauseAnimation.bind(this); this.pauseAnimation = this.pauseAnimation.bind(this); this.stopAnimation = this.stopAnimation.bind(this); this.resetAnimation = this.resetAnimation.bind(this); this._onPropertyChange = this._onPropertyChange.bind(this); this._postFrame = this._postFrame.bind(this); this._wheel = this._wheel.bind(this); this._scroll = this._scroll.bind(this); this._pointerDragHelper.addEventListener('drag', this._drag.bind(this)); } setTime(time) { this._animationTime = Math.max(0, Math.min(time, this._animationDuration)); } onAdded(viewer) { super.onAdded(viewer); viewer.scene.addEventListener('addSceneObject', this._objectAdded); viewer.addEventListener('postFrame', this._postFrame); window.addEventListener('wheel', this._wheel); window.addEventListener('scroll', this._scroll); this._pointerDragHelper.element = viewer.canvas; } onRemove(viewer) { while (this.animations.length) this.animations.pop(); viewer.scene.removeEventListener('addSceneObject', this._objectAdded); viewer.removeEventListener('postFrame', this._postFrame); window.removeEventListener('wheel', this._wheel); window.removeEventListener('scroll', this._scroll); this._pointerDragHelper.element = undefined; return super.onRemove(viewer); } onStateChange() { this.uiConfig?.uiRefresh?.(true, 'postFrame'); // this.uiConfig?.children?.map(value => value && getOrCall(value)).flat(2).forEach(v=>v?.uiRefresh?.()) } /** * This will play a single clip by name * It might reset all other animations, this is a bug; https://codepen.io/repalash/pen/mdjgpvx * @param name * @param resetOnEnd */ async playClip(name, resetOnEnd = false) { return this.playClips([name], resetOnEnd); } async playClips(names, resetOnEnd = false) { const anims = []; this.animations.forEach(({ actions }) => { actions.forEach((action) => { if (names.includes(action.getClip().name)) { anims.push(action); } }); }); return this.playAnimation(resetOnEnd, anims); } /** * Starts all the animations and returns a promise that resolves when all animations are done. * @param resetOnEnd - if true, will reset the animation to the start position when it ends. * @param animations - play specific animations, otherwise play all animations. Note: the promise returned (if this is set) from this will resolve before time if the animations was ever paused, or converged mode is on in recorder. */ async playAnimation(resetOnEnd = false, animations) { if (this.isDisabled()) return; let wasPlaying = false; if (this._animationState === 'playing') { this.stopAnimation(false); // stop and play again. reset is done below. wasPlaying = true; } // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', false) let duration = 0; const isAllAnimations = !animations; if (!animations) { animations = []; this.animations.forEach(({ actions }) => { // console.log(mixer, actions, clips) animations.push(...actions); }); } if (wasPlaying) this.resetAnimation(); else if (this.animationState !== 'paused') { animations.forEach((ac) => { ac.reset(); }); this._animationTime = 0; } const id = generateUUID(); this._lastAnimId = id; // todo: check logic for (const ac of animations) { // if (Math.abs(this.timeScale) > 0) { // if (!(ac as any)._tTimeScale) (ac as any)._tTimeScale = ac.timeScale // ac.timeScale = this.timeScale // } else if ((ac as any)._tTimeScale) ac.timeScale = (ac as any)._tTimeScale ac.setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions); ac.play(); duration = Math.max(duration, ac.getClip().duration / Math.abs(ac.timeScale)); // if (!this._playingActions.includes(ac)) this._playingActions.push(ac) // console.log(ac) } this._animationState = 'playing'; this._viewer?.setDirty(); if (!isAllAnimations) { const loops = this.loopAnimations ? this.loopRepetitions : 1; duration *= loops; if (!isFinite(duration)) { // infinite animation return; } await new Promise((resolve) => { const listen = (e) => { if (e.time >= duration) { this.removeEventListener('animationStep', listen); resolve(); } }; this.addEventListener('animationStep', listen); }); // const animDuration = 1000 * duration - this._animationTime / this.animationSpeed + 0.01 // // if (animDuration > 0) { // await timeout(animDuration) // return // } // todo: handle pausing/early stop, converge mode for single animation playback } else { if (!isFinite(this._animationDuration)) { // infinite animation return; } await new Promise((resolve) => { const listen = () => { this.removeEventListener('checkpointEnd', listen); resolve(); }; this.addEventListener('checkpointEnd', listen); }); } if (id === this._lastAnimId) { // in-case multiple animations are started. this.stopAnimation(resetOnEnd); } return; } pauseAnimation() { if (this._animationState !== 'playing') { console.warn('pauseAnimation called when animation was not playing.'); return; } this._animationState = 'paused'; // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true) this._viewer?.setDirty(); // this._lastAnimId = '' // this disables stop on timeout end, for now. } resumeAnimation() { if (this._animationState !== 'paused') { console.warn('resumeAnimation called when animation was not paused.'); return; } this._animationState = 'playing'; // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true) this._viewer?.setDirty(); } stopAnimation(reset = false) { this._animationState = 'stopped'; // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking'), 'enabled', true) if (reset) this.resetAnimation(); else this._viewer?.setDirty(); this._lastAnimId = ''; if (this._viewer && this._fadeDisabled) { this._viewer.getPlugin('FrameFade')?.enable(this); this._fadeDisabled = false; } } resetAnimation() { if (this._animationState !== 'stopped' && this._animationState !== 'none') { this.stopAnimation(true); // reset and stop return; } this.animations.forEach(({ mixer }) => { // console.log(mixer, actions, clips) mixer.stopAllAction(); mixer.setTime(0); }); this._animationTime = 0; this._viewer?.setDirty(); } _postFrame() { if (!this._viewer) return; const scrollAnimate = this.animateOnScroll; // && this._animationState === 'paused' const pageScrollAnimate = this.animateOnPageScroll; // && this._animationState === 'paused' const dragAnimate = this.animateOnDrag; // && this._animationState === 'paused' if (this.isDisabled() || this.animations.length < 1 || this._animationState !== 'playing' && !scrollAnimate && !dragAnimate && !pageScrollAnimate) { this._lastFrameTime = 0; // console.log('not anim') if (this._fadeDisabled) { this._viewer.getPlugin('FrameFade')?.enable(this); this._fadeDisabled = false; } return; } if (this._animationTime < 0.0001) { this.dispatchEvent({ type: 'checkpointBegin' }); } if (this.autoIncrementTime) { const time = now() / 1000.0; if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 30.0; let delta = time - this._lastFrameTime; delta *= this.animationSpeed; this._lastFrameTime = time; if (pageScrollAnimate) delta *= this._pageScrollAnimationState; else if (scrollAnimate && dragAnimate) delta *= absMax(this._scrollAnimationState, this._dragAnimationState); else if (scrollAnimate) delta *= this._scrollAnimationState; else if (dragAnimate) delta *= this._dragAnimationState; if (Math.abs(delta) < 0.0001) return; const d = this._viewer.getPlugin('Progressive')?.postFrameConvergedRecordingDelta(); if (d && d > 0) delta = d; if (d === 0) return; // not converged yet. // if d < 0: not recording, do nothing const ts = Math.abs(this.timeScale); this._animationTime += delta * (ts > 0 ? ts : 1); } const animDelta = this._animationTime - this._lastAnimationTime; this._lastAnimationTime = this._animationTime; const t = this.timeScale < 0 ? (isFinite(this._animationDuration) ? this._animationDuration : 0) - this._animationTime : this._animationTime; this.animations.map(a => { // a.mixer.timeScale = -1 a.mixer.setTime(t); }); if (Math.abs(animDelta) < 0.00001) return; // if (this._animationTime > this._animationDuration) this._animationTime -= this._animationDuration // if (this._animationTime < 0) this._animationTime += this._animationDuration this._pageScrollAnimationState = this.pageScrollTime - this._animationTime; if (Math.abs(this._pageScrollAnimationState) < 0.001) this._pageScrollAnimationState = 0; else this._pageScrollAnimationState *= 1.0 - this.pageScrollAnimationDamping; if (Math.abs(this._scrollAnimationState) < 0.001) this._scrollAnimationState = 0; else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping; if (Math.abs(this._dragAnimationState) < 0.001) this._dragAnimationState = 0; else this._dragAnimationState *= 1.0 - this.dragAnimationDamping; this.dispatchEvent({ type: 'animationStep', delta: animDelta, time: t }); // todo: this is now checked preFrame in ThreeViewer.ts // if (this._viewer.scene.mainCamera.userData.isAnimating) { // if camera is animating // this._viewer.scene.mainCamera.setDirty() // console.log(this._viewer.scene.mainCamera, this._viewer.scene.mainCamera.getWorldPosition(new Vector3())) // } this._viewer.renderManager.resetShadows(); this._viewer.setDirty(); if (!this._fadeDisabled) { const ff = this._viewer.getPlugin('FrameFade'); if (ff) { ff.disable(GLTFAnimationPlugin_1.PluginType); this._fadeDisabled = true; } } if (this._animationTime >= this._animationDuration) { this.dispatchEvent({ type: 'checkpointEnd' }); } } _onPropertyChange(replay = true) { this._animationDuration = Math.max(...this.animations.map(({ duration }) => duration)) * (this.loopAnimations ? this.loopRepetitions : 1); if (this._animationState === 'playing' && replay) { this.playAnimation(); } } get pageScrollTime() { const scrollMax = this.pageScrollHeight(); const time = window.scrollY / scrollMax * (this.animationDuration - 0.05); return time; } _scroll() { if (this.isDisabled()) return; this._pageScrollAnimationState = this.pageScrollTime - this.animationTime; } _wheel({ deltaY }) { if (this.isDisabled()) return; if (Math.abs(deltaY) > 0.001) this._scrollAnimationState = -1. * Math.sign(deltaY); } _drag(ev) { if (this.isDisabled() || !this._viewer) return; this._dragAnimationState = this.dragAxis === 'x' ? ev.delta.x * this._viewer.canvas.width / 4 : ev.delta.y * this._viewer.canvas.height / 4; } }; GLTFAnimationPlugin.PluginType = 'GLTFAnimation'; __decorate([ serialize() ], GLTFAnimationPlugin.prototype, "autoIncrementTime", void 0); __decorate([ onChange2(GLTFAnimationPlugin.prototype._onPropertyChange), uiToggle('Loop'), serialize() ], GLTFAnimationPlugin.prototype, "loopAnimations", void 0); __decorate([ onChange2(GLTFAnimationPlugin.prototype._onPropertyChange), serialize() ], GLTFAnimationPlugin.prototype, "loopRepetitions", void 0); __decorate([ uiSlider('Timescale', [-2, 2], 0.01), serialize() ], GLTFAnimationPlugin.prototype, "timeScale", void 0); __decorate([ uiSlider('Speed', [0.1, 4], 0.1), serialize() ], GLTFAnimationPlugin.prototype, "animationSpeed", void 0); __decorate([ uiToggle(), serialize() ], GLTFAnimationPlugin.prototype, "animateOnScroll", void 0); __decorate([ uiSlider('Scroll Damping', [0, 1]), serialize() ], GLTFAnimationPlugin.prototype, "scrollAnimationDamping", void 0); __decorate([ uiToggle(), serialize() ], GLTFAnimationPlugin.prototype, "animateOnPageScroll", void 0); __decorate([ uiSlider('Page Scroll Damping', [0, 1]), serialize() ], GLTFAnimationPlugin.prototype, "pageScrollAnimationDamping", void 0); __decorate([ uiToggle(), serialize() ], GLTFAnimationPlugin.prototype, "animateOnDrag", void 0); __decorate([ uiDropdown('Drag Axis', [{ label: 'x' }, { label: 'y' }]), serialize() ], GLTFAnimationPlugin.prototype, "dragAxis", void 0); __decorate([ uiSlider('Drag Damping', [0, 1]), serialize() ], GLTFAnimationPlugin.prototype, "dragAnimationDamping", void 0); __decorate([ uiToggle(), serialize() ], GLTFAnimationPlugin.prototype, "autoplayOnLoad", void 0); __decorate([ uiToggle('syncMaxDuration(dev)'), serialize() ], GLTFAnimationPlugin.prototype, "syncMaxDuration", void 0); __decorate([ uiMonitor() ], GLTFAnimationPlugin.prototype, "animationState", null); __decorate([ uiMonitor() ], GLTFAnimationPlugin.prototype, "animationTime", null); __decorate([ uiMonitor() ], GLTFAnimationPlugin.prototype, "animationDuration", null); __decorate([ uiButton('Play/Pause', (that) => ({ label: () => that.animationState === 'playing' ? 'Pause' : 'Play', })) ], GLTFAnimationPlugin.prototype, "playPauseAnimation", null); __decorate([ onChange(GLTFAnimationPlugin.prototype.onStateChange) ], GLTFAnimationPlugin.prototype, "_animationState", void 0); __decorate([ uiButton('Stop', { sendArgs: false }) ], GLTFAnimationPlugin.prototype, "stopAnimation", null); __decorate([ uiButton('Reset', { sendArgs: false }) ], GLTFAnimationPlugin.prototype, "resetAnimation", null); GLTFAnimationPlugin = GLTFAnimationPlugin_1 = __decorate([ uiFolderContainer('GLTF Animations') ], GLTFAnimationPlugin); export { GLTFAnimationPlugin }; //# sourceMappingURL=GLTFAnimationPlugin.js.map