threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
404 lines • 15.6 kB
JavaScript
import { now } from 'ts-browser-helpers';
import { animate } from '@repalash/popmotion'; // todo: its not able to import from fork anymore since animateKeyframes is used, it can be imported from main.
import { AViewerPluginSync } from '../../viewer';
import { generateUUID } from '../../three';
import { animateCameraToViewLinear, animateCameraToViewSpherical, EasingFunctions, extractAnimationKey, makeSetterFor, } from '../../utils';
import { animateKeyframes } from '../../utils/animation';
/**
* Popmotion plugin
*
* Provides animation capabilities to the viewer using the popmotion library: https://popmotion.io/
*
* Overrides the driver in popmotion to sync with the viewer and provide ways to keep track and stop animations.
*
* @category Plugins
*/
export class PopmotionPlugin extends AViewerPluginSync {
constructor(enabled = true) {
super();
this.enabled = true;
this.toJSON = undefined; // disable serialization
this.fromJSON = undefined; // disable serialization
// private _animating = false
this._lastFrameTime = 0; // for post frame
this._updaters = [];
this.dependencies = [];
this._fadeDisabled = false;
/**
* Disable the frame fade plugin while animation is running
*/
this.disableFrameFade = true;
this.autoIncrementTime = true;
// Same code used in CameraViewPlugin
this._postFrame = () => {
if (!this._viewer)
return;
if (this.isDisabled() || Object.keys(this._updaters).length < 1) {
this._lastFrameTime = 0;
// console.log('not anim')
if (this._fadeDisabled) {
this._viewer.getPlugin('FrameFade')?.enable(this);
this._fadeDisabled = false;
}
return;
}
let delta;
if (this.autoIncrementTime) {
const time = now() / 1000.0;
if (this._lastFrameTime < 1)
this._lastFrameTime = time - 1.0 / 60.0;
delta = time - this._lastFrameTime;
this._lastFrameTime = time;
const d = this._viewer.getPlugin('Progressive')?.postFrameConvergedRecordingDelta();
if (d && d > 0)
delta = d;
if (d === 0)
delta = 0; // not converged yet.
// if d < 0: not recording, do nothing
}
else {
const time = this._viewer.timeline.time;
// if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
delta = time - this._lastFrameTime;
this._lastFrameTime = time;
}
// todo: scrolling
// delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1)
delta *= 1000;
// delta = 16.666 // testing
if (Math.abs(delta) <= 0.0001)
return;
this._updaters.forEach(u => {
let dt = delta;
if (u.time + dt < 0)
dt = -u.time;
u.time += dt;
if (Math.abs(dt) > 0.001)
u.u(dt);
});
if (!this._fadeDisabled && this.disableFrameFade) {
const ff = this._viewer.getPlugin('FrameFade');
if (ff) {
ff.disable(this);
this._fadeDisabled = true;
}
}
// todo: scrolling
// if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0
// else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
};
this.defaultDriver = (update) => {
return {
start: () => this._updaters.push({ u: update, time: 0 }),
stop: () => this._updaters.splice(this._updaters.findIndex(u => u.u === update), 1),
};
};
this.animations = {};
this.enabled = enabled;
this._postFrame = this._postFrame.bind(this);
}
onAdded(viewer) {
super.onAdded(viewer);
viewer.addEventListener('postFrame', this._postFrame);
}
onRemove(viewer) {
viewer.removeEventListener('postFrame', this._postFrame);
super.onRemove(viewer);
}
animate(options1, animateFunc) {
const { target, key, ...options } = { ...options1 };
let from = options.from;
let to = options.to;
if (target !== undefined) {
if (key === undefined)
throw new Error('PopmotionPlugin - key must be defined when animating in target');
if (!(key in target)) {
this._viewer?.console.warn('PopmotionPlugin - key not present in target, creating', key, target);
target[key] = from ?? 0;
}
const setter = makeSetterFor(target, key);
const fromVal = () => target[key];
options.lastOnUpdate = options.onUpdate;
options.onUpdate = (val) => {
setter(val);
options.lastOnUpdate && options.lastOnUpdate(val);
};
if (from === undefined && (!Array.isArray(to) || to.length < 2))
from = fromVal();
}
const a = this.createAnimationResult(options);
a.promise = new Promise((resolve, reject) => {
const end2 = () => {
try {
options.onEnd && options.onEnd();
}
catch (e) {
reject(e);
return false;
}
return true;
};
//
// const kf = []
// const off = []
// if (from !== undefined) {
// kf.push(from)
// off.push(0)
// console.log('from', from, options)
// }
// if (Array.isArray(to)) {
// kf.push(...to)
// const opOff = (options as KeyframeOptions).offset || []
// for (const n of opOff) {
// off.push(n)
// }
// if (to.length !== opOff.length) {
// console.warn('PopmotionPlugin - to and offset length mismatch', kf, off, options)
// for (let i = opOff.length; i < to.length; i++) {
// off.push(1)
// }
// }
// } else {
// if (to !== undefined) {
// kf.push(to)
// off.push(1)
// }
// }
// from = kf[0] as any
const from1 = from ?? (Array.isArray(to) ? to[0] : from);
if (from1 === undefined) {
console.warn('from is undefined', options);
resolve();
return;
}
const isBool = typeof from1 === 'boolean';
// const duration = (options as KeyframeOptions).duration
// if (duration !== undefined && delay !== undefined && delay > 0 && kf.length > 0) {
// kf.splice(1, 0, from)
// off.splice(1, 0, delay / (duration + delay))
// }
// console.log(kf, off)
if (Array.isArray(to) && to.length < 2) {
to = to[0];
}
const opts = {
...options,
driver: options.driver || this.defaultDriver,
// duration: duration !== undefined ? duration + (delay || 0) : undefined,
// to: !isBool ? [...kf] as any : kf.map((v: number)=>v >= 1 ? true : false) as any,
to: to,
from: from,
// from: undefined,
// to: options.to,
// from: options.from,
// offset: [...off],
onUpdate: (v) => {
if (!options.onUpdate)
return;
// console.log(v)
if (isBool)
options.onUpdate(v >= 1 ? true : false);
else
options.onUpdate(v);
},
onComplete: async () => {
// a.completed = true
// this._drivers[a.id]?.stop()
try {
// if (isBool && !this.animations[uuid].stopped) options.onUpdate?.(to as any)
options.onComplete && await options.onComplete();
}
catch (e) {
if (!end2())
return;
reject(e);
return;
}
if (!end2())
return;
resolve();
},
onStop: async () => {
try {
options.onStop && await options.onStop();
}
catch (e) {
if (!end2())
return;
reject(e);
return;
}
resolve();
},
};
const anim = animateFunc ? animateFunc(opts) : animate(opts);
a._stop = anim.stop;
a.options = opts;
}).then(() => {
delete this.animations[a.id];
return a.id;
});
return a;
}
async animateAsync(options, animations) {
const anim = this.animate(options);
if (animations)
animations.push(anim);
return anim.promise;
}
// region animation utils
/**
* Similar to animate, but specifically for numbers, defaults from 0 to 1. Also calls onUpdate with the delta value.
* @param options
*/
animateNumber(options) {
let lastVal = options.from ?? 0;
return this.animate({
...options,
from: lastVal,
to: options.to ?? 1,
onUpdate: (v) => {
const dv = v - lastVal;
lastVal = v;
options.onUpdate && options.onUpdate(v, dv);
},
});
}
timeout(ms, options /* &{delay?: number, canComplete?: boolean}*/) {
return this.animate({
from: 0, to: ms, duration: ms,
...options,
});
}
async animateTargetAsync(target, key, options, animations) {
const anim = this.animate({ ...options, target, key: key });
if (animations)
animations.push(anim);
return anim.promise;
}
/**
* @deprecated - use {@link animate} instead
* @param target
* @param key
* @param options
*/
animateTarget(target, key, options /* &{delay?: number, canComplete?: boolean}*/) {
return this.animate({ ...options, target, key: key });
}
animateCamera(camera, view, spherical = true, options) {
const anim = spherical ?
animateCameraToViewSpherical(camera, view) :
animateCameraToViewLinear(camera, view);
return this.animate({
ease: EasingFunctions.linear,
...anim, ...options,
duration: (options.duration ?? 1000) * (view.duration ?? 1),
});
}
async animateCameraAsync(camera, view, spherical = true, options, animations) {
const anim = this.animateCamera(camera, view, spherical, options);
if (animations)
animations.push(anim);
return anim.promise;
}
// endregion animation utils
createAnimationResult(options = {}) {
const uuid = generateUUID();
return this.animations[uuid] = {
id: uuid,
options: options,
stop: () => this.stopAnimationResult(uuid),
stopped: false,
['_stop']: () => {
return;
},
anims: [],
promise: undefined,
// completed: false,
};
}
stopAnimationResult(uuid) {
const a1 = this.animations[uuid];
if (!a1 || a1.stopped)
return;
if (!a1._stop)
console.warn('Animation not started');
else if (typeof a1._stop === 'function')
a1._stop();
a1.anims?.forEach(anim => anim.stop());
a1.stopped = true;
}
// region animation object
animateObject(o, delay, canComplete = true, driver, delay2) {
// if (typeof o.animate === 'function' && _external) {
// return o.animate(delay, canComplete)
// }
const { key: key1, tar, onChange: accOnUpdate } = extractAnimationKey(o);
let key = key1;
if (tar && key && !(key in tar)) {
console.error('PopmotionPlugin invalid key', key, tar, o);
// throw ''
key = undefined;
}
const a = this.createAnimationResult(o.options);
if (canComplete)
o.result = a;
delay = (delay || 0) + ((delay2 ?? o.delay) || 0);
a.anims = o.animSet ? [...a.anims, this.animateSet(o.animSet, o.animSetParallel ?? false, delay, canComplete, driver)] : a.anims;
const oUpdate = o.updater ?? [];
const opts = !key || !tar ? {
to: [0, 1],
} : {
target: tar, key,
to: o.values,
offset: o.offsets,
};
// todo add repeat, repeatDelay, repeatType by changing `to` and duration
a.anims.push(this.animate({
...opts,
driver,
ease: typeof o.ease === 'string' ? EasingFunctions[o.ease] : o.ease,
duration: o.duration,
...o.options,
// @ts-expect-error implemented in animateKeyframes
canComplete, delay,
onUpdate: (v) => {
o.options.onUpdate && o.options.onUpdate(v);
accOnUpdate && accOnUpdate();
oUpdate.forEach(value => value && value());
},
}, animateKeyframes)); // animateKeyframes implements delay and canComplete
a.promise = Promise.all(a.anims.map(async (n) => n.promise)).then(() => {
// a.completed = true
a.anims = [];
delete this.animations[a.id];
if (o.result === a)
o.result = undefined;
return a.id;
});
return a;
}
animateSet(anims, parallel = false, delay1 = 0, canComplete = true, driver) {
const a = this.createAnimationResult();
if (parallel) {
a.anims = anims.map(anim => this.animateObject(anim, delay1, canComplete, driver));
}
else {
let d = delay1;
for (const anim of anims) {
a.anims.push(this.animateObject(anim, d, canComplete, driver));
const { delay = 0, duration = 0, options, } = anim;
d += delay + duration + (duration + (options.repeatDelay || 0)) * (options.repeat || 0);
}
}
a.promise = Promise.all(a.anims.map(async (n) => n.promise)).then(() => {
// a.completed = true
a.anims = [];
delete this.animations[a.id];
return a.id;
});
return a;
}
}
PopmotionPlugin.PluginType = 'PopmotionPlugin';
//# sourceMappingURL=PopmotionPlugin.js.map