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.
471 lines • 21.1 kB
JavaScript
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;
};
import { getOrCall, serialize } from 'ts-browser-helpers';
import { AViewerPluginSync } from '../../viewer';
import { generateUiConfig } from 'uiconfig.js';
import { PopmotionPlugin } from './PopmotionPlugin';
import { AnimationObject } from '../../utils/AnimationObject';
import { createDiv, createStyles, UndoManagerPlugin } from '../../index';
// @uiFolder('Viewer Animations') // todo rename plugin to ViewerAnimationsPlugin?
export class AnimationObjectPlugin extends AViewerPluginSync {
// /**
// * Animations that are tracked in the scene, bound to objects, materials etc
// */
// animations: Set<AnimationObject> = new Set()
getAllAnimations() {
return [...this.animation.animSet, ...this.runtimeAnimation.animSet];
}
_getActiveIndex(ao) {
if (!ao.target)
return '';
const cTime = 1000 * (this._viewer?.timeline.time || 0); // current time in ui
const localTime = (cTime - ao.delay) / ao.duration;
const offsetTimes = ao.offsets;
const closestIndex = offsetTimes.reduce((prev, curr, index) => {
return Math.abs(curr - localTime) < Math.abs(offsetTimes[prev] - localTime) ? index : prev;
}, 0);
const dist = Math.abs(offsetTimes[closestIndex] - localTime);
const activeIndex = dist * ao.duration < 50 ? closestIndex.toString() : '';
return activeIndex;
}
get triggerButtonsShown() {
return this._triggerButtonsShown;
}
set triggerButtonsShown(v) {
this._triggerButtonsShown = v;
if (v)
document.body.classList.add('aouic-triggers-visible');
else
document.body.classList.remove('aouic-triggers-visible');
}
showTriggers(v = true) {
this.triggerButtonsShown = v;
}
constructor() {
super();
this.enabled = true;
this.dependencies = [PopmotionPlugin];
/**
* Main animation with target = viewer for global properties
*/
this.animation = new AnimationObject(() => this._viewer, () => this._viewer, 'Viewer Animation');
this.runtimeAnimation = new AnimationObject(undefined, () => this._viewer, 'Runtime Animation');
// private _fAnimationAdd = (e: Event2<'animationAdd', AnimationObjectEventMap, AnimationObject>)=>{
// this.rebuildTimeline()
// this.dispatchEvent(e)
// }
this._fAnimationAdd = (e) => {
this.rebuildTimeline();
this.dispatchEvent({ ...e, type: 'animationAdd' });
};
this._fAnimationRemove = (e) => {
this.rebuildTimeline();
this.dispatchEvent(e);
if (e.fromChild && e.target === this.runtimeAnimation) {
const obj = e.animation.target;
if (obj?.userData?.animationObjects)
this._removeAnimationFromObject(e.animation, obj);
const visibleBtns = this._visibleBtns.get(e.animation);
if (visibleBtns) {
visibleBtns.forEach(btn => this._refreshTriggerBtn(e.animation, btn));
}
}
else {
this._visibleBtns.delete(e.animation);
}
};
this._fAnimationUpdate = (e) => {
this.rebuildTimeline();
this.dispatchEvent({ ...e, type: 'animationUpdate', animation: e.target });
const visibleBtns = this._visibleBtns.get(e.target);
if (visibleBtns) {
visibleBtns.forEach(btn => this._refreshTriggerBtn(e.target, btn));
}
};
this._viewerTimelineUpdate = () => {
if (!this._viewer)
return;
this._visibleBtns.forEach((btns, ao) => {
btns.forEach(btn => this._refreshTriggerBtn(ao, btn));
});
};
this._refreshTriggerBtn = (ao, btn) => {
const activeIndex = this._getActiveIndex(ao);
btn.dataset.activeIndex = activeIndex;
if (activeIndex.length) {
btn.classList.add('anim-object-uic-trigger-active');
}
else {
btn.classList.remove('anim-object-uic-trigger-active');
}
};
this._triggerButtonsShown = false;
// uiConfig = this.animation.uiConfig
this._currentTimeline = [];
this._refTimeline = false;
this._viewerListeners = {
postFrame: () => {
const pop = this._viewer?.getPlugin(PopmotionPlugin);
if (this._refTimeline && pop) {
this._refTimeline = false;
this._currentTimeline.forEach(([_, r]) => r.stop());
this._currentTimeline = this.getAllAnimations().map(o => [o, pop.animateObject(o, 0, false, this.popmotionDriver)]);
this.dispatchEvent({ type: 'rebuildTimeline', timeline: this._currentTimeline });
}
},
preFrame: () => {
if (!this._viewer)
return;
if (this.isDisabled() || Object.keys(this._updaters).length < 1) {
this._lastFrameTime = 0;
return;
}
const time = this._viewer.timeline.time * 1000;
// if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
const delta = time - this._lastFrameTime;
this._lastFrameTime = time;
if (Math.abs(delta) <= 0.0001)
return;
this._updaters.forEach(u => {
let dt = delta;
if (u.time !== time)
dt = time - u.time;
if (u.time + dt < 0)
dt = -u.time;
u.time += dt;
if (Math.abs(dt) > 0.001)
u.u(dt);
});
},
};
this._objectAdd = (e) => {
const obj = e.object;
if (!obj)
return;
if (Array.isArray(obj.userData.animationObjects)) {
obj.userData.animationObjects.forEach(ao => this._addAnimationObject(ao, obj));
}
this._setupUiConfig(obj);
};
this._objectRemove = (e) => {
const obj = e.object;
if (!obj)
return;
if (Array.isArray(obj.userData.animationObjects)) {
obj.userData.animationObjects.forEach(ao => this._removeAnimationObject(ao));
}
this._cleanUpUiConfig(obj);
};
this._materialAdd = (e) => {
const obj = e.material;
if (!obj)
return;
if (Array.isArray(obj.userData.animationObjects)) {
obj.userData.animationObjects.forEach(ao => this._addAnimationObject(ao, obj));
}
this._setupUiConfig(obj);
};
this._materialRemove = (e) => {
const obj = e.material;
if (!obj)
return;
if (Array.isArray(obj.userData.animationObjects)) {
obj.userData.animationObjects.forEach(ao => this._removeAnimationObject(ao));
}
this._cleanUpUiConfig(obj);
};
this._visibleBtns = new Map();
this._iObservers = new Map();
this._lastFrameTime = 0; // for post frame, in ms
this._updaters = [];
this.popmotionDriver = (update) => ({
start: () => this._updaters.push({ u: update, time: 0 }),
stop: () => {
this._updaters.splice(this._updaters.findIndex(u => u.u === update), 1);
},
});
// override ui config for flatten hierarchy (for now)
this.uiConfig = {
label: 'Viewer Animations',
type: 'folder',
children: [
generateUiConfig(this.animation).filter(c => {
const label = getOrCall(c?.label) ?? '';
// if (label === ('animSet' as (keyof AnimationObject))) return c.children
return ['Animate', 'Stop', 'Animate Reverse'].includes(label);
}) ?? [],
() => {
const c = generateUiConfig(this.animation.animSet);
return c.map(d => getOrCall(d)).filter(Boolean);
},
{
type: 'checkbox',
label: 'Run in Parallel',
property: [this.animation, 'animSetParallel'],
},
{
type: 'button',
label: 'Add Animation',
value: () => {
this.animation.addAnimation();
this.uiConfig.uiRefresh?.(true, 'postFrame', 1);
},
},
{
type: 'checkbox',
label: 'Show Triggers',
property: [this, 'triggerButtonsShown'],
},
// {
// type: 'button',
// label: 'Clear Animations',
// value: ()=>{
// this.animation.animSet = []
// this.animation.refreshUi()
// },
// }
],
};
this.animation.animSetParallel = true;
this.animation.uiConfig.uiRefresh = (...args) => this.uiConfig.uiRefresh?.(...args);
this.animation.addEventListener('animationAdd', this._fAnimationAdd);
this.animation.addEventListener('animationRemove', this._fAnimationRemove);
this.animation.addEventListener('update', this._fAnimationUpdate);
this.runtimeAnimation.animSetParallel = true;
this.runtimeAnimation.uiConfig.uiRefresh = (...args) => this.uiConfig.uiRefresh?.(...args);
this.runtimeAnimation.addEventListener('animationAdd', this._fAnimationAdd);
this.runtimeAnimation.addEventListener('animationRemove', this._fAnimationRemove);
this.runtimeAnimation.addEventListener('update', this._fAnimationUpdate);
this._fAnimationAdd({ animation: this.animation });
createStyles(`
.anim-object-uic-trigger{
padding: 4px;
margin-top: -4px;
cursor: pointer;
color: var(--tp-label-foreground-color, #777);
display: none;
}
.anim-object-uic-trigger-visible{
}
.anim-object-uic-trigger-active{
color: red;
}
.aouic-triggers-visible .anim-object-uic-trigger{
display: inline-block;
}
`);
}
rebuildTimeline() {
this._refTimeline = true;
}
getTimeline() {
return this._currentTimeline;
}
_addAnimationObject(ao, obj) {
ao.target = obj;
this.runtimeAnimation.add(ao);
}
_removeAnimationObject(ao) {
this.runtimeAnimation.remove(ao);
ao.target = undefined;
}
_removeAnimationFromObject(ao, obj) {
ao.target = undefined;
if (!obj.userData.animationObjects)
return;
const ind = obj.userData.animationObjects.indexOf(ao);
if (ind >= 0) {
obj.userData.animationObjects.splice(ind, 1);
if (obj.userData.animationObjects.length < 1) {
delete obj.userData.animationObjects;
}
}
}
_setupUiConfig(obj) {
const type = obj.isObject3D ? 'objects' : obj.isMaterial ? 'materials' : undefined;
if (!type)
return;
obj.uiConfig?.children?.push({
type: 'folder',
label: 'Property Animations',
tags: ['animation', AnimationObjectPlugin.PluginType],
children: [() => obj.userData.animationObjects?.map(ao => ao.uiConfig)],
});
const components = this._animatableUiConfigs(obj);
for (const config of components) {
const prop = getOrCall(config.property); // todo use uiconfigmethods
if (!prop)
continue;
const [tar, key] = prop;
if (!tar || typeof key !== 'string')
continue;
const btn = createDiv({ innerHTML: '◆', classList: ['anim-object-uic-trigger'] });
btn.dataset.isAnimObjectTrigger = '1';
btn.title = 'Add Animation for ' + getOrCall(config.label, key); // todo use uiconfigmethods
const getAo = () => {
if (!obj.userData.animationObjects)
obj.userData.animationObjects = [];
return obj.userData.animationObjects.find(o => o.access === key);
};
btn.addEventListener('click', () => {
const undo = this._viewer?.getPlugin(UndoManagerPlugin); // todo use uiconfigmethods
let ao = getAo();
const cTime = 1000 * (this._viewer?.timeline.time || 0); // current time in ui
if (!ao) {
ao = new AnimationObject();
// ao.access = type + '.' + obj.uuid + '.' + key
ao.access = key;
ao.name = obj.name + ' ' + (getOrCall(config.label, key) || key);
ao.updateTarget = true; // calls setDirty on obj on any change
ao.delay = cTime; // current time in ui
ao.duration = 2000;
const cao = ao;
const c = {
redo: () => {
if (!obj.userData.animationObjects)
obj.userData.animationObjects = [];
obj.userData.animationObjects.push(cao);
this._addAnimationObject(cao, obj);
this._refreshTriggerBtn(cao, btn);
},
undo: () => {
cao.removeFromParent(); // this will dispatch with fromChild = true
this._refreshTriggerBtn(cao, btn);
},
};
c.redo();
undo?.undoManager?.record(c);
}
else if (ao.values.length > 1) {
const cao = ao;
const shownActiveIndex = btn.dataset.activeIndex || '';
const activeIndex = this._getActiveIndex(ao);
if (activeIndex === shownActiveIndex) {
const index = parseInt(activeIndex || '-1');
const ref = () => this._refreshTriggerBtn(cao, btn);
if (undo) {
if (index < 0)
undo.performAction(ao, ao.addKeyframe, [cTime], 'addKeyframe-' + ao.access, ref);
else
undo.performAction(ao, ao.updateKeyframe, [index], 'editKeyframe-' + ao.access, ref);
ref();
}
else {
if (index < 0)
ao.addKeyframe(cTime);
else
ao.updateKeyframe(index);
ref();
}
}
else {
// todo something else is shown in ui, maybe user didnt want this
console.error('Active index mismatch', activeIndex, shownActiveIndex);
}
}
// btn.remove()
// config.domChildren = !config.domChildren || Array.isArray(config.domChildren) ? config.domChildren?.filter(d => d !== btn) || [] : config.domChildren
});
const btnObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.target !== btn)
continue;
const ao = getAo();
if (!ao)
continue;
if (!this._visibleBtns.has(ao))
this._visibleBtns.set(ao, new Set());
const btns = this._visibleBtns.get(ao);
// console.log(entry.isIntersecting)
if (entry.isIntersecting) {
if (!btns.has(btn)) {
btn.classList.add('anim-object-uic-trigger-visible');
btns.add(btn);
// timeline time change
// animation object change
}
}
else {
btn.classList.remove('anim-object-uic-trigger-visible');
btns.delete(btn);
}
}
});
btnObserver.observe(btn);
if (!this._iObservers.has(obj.uuid))
this._iObservers.set(obj.uuid, new Set());
this._iObservers.get(obj.uuid)?.add(btnObserver);
const ao = getAo();
if (ao)
this._refreshTriggerBtn(ao, btn);
config.domChildren = !config.domChildren || Array.isArray(config.domChildren) ? [...config.domChildren || [], btn] : config.domChildren;
}
}
_cleanUpUiConfig(obj) {
const components = this._animatableUiConfigs(obj);
const observers = this._iObservers.get(obj.uuid);
for (const config of components) {
config.domChildren = Array.isArray(config.domChildren) ? config.domChildren?.filter(d => !(d instanceof HTMLElement && d.dataset.isAnimObjectTrigger)) || [] : config.domChildren;
}
if (observers) {
observers.forEach(o => o.disconnect());
observers.clear();
this._iObservers.delete(obj.uuid);
}
}
_animatableUiConfigs(obj) {
return obj.uiConfig?.children?.filter(c => typeof c === 'object' && c.type &&
['vec3', 'color', 'number', 'checkbox', 'toggle'].includes(c.type) &&
Array.isArray(c.property) && c.property[0] === obj) || [];
}
onAdded(viewer) {
super.onAdded(viewer);
viewer.object3dManager.addEventListener('objectAdd', this._objectAdd);
viewer.object3dManager.addEventListener('objectRemove', this._objectRemove);
viewer.object3dManager.addEventListener('materialAdd', this._materialAdd);
viewer.object3dManager.addEventListener('materialRemove', this._materialRemove);
viewer.timeline.addEventListener('update', this._viewerTimelineUpdate);
viewer._animGetters = {
objects: (name, acc) => {
if (!viewer)
return undefined;
const obj = viewer.object3dManager.findObject(name);
return { tar: obj, acc, onChange: obj ? () => {
obj.setDirty && obj.setDirty({ refreshScene: false, frameFade: false });
} : undefined };
},
materials: (name, acc) => {
if (!viewer)
return undefined;
const mat = viewer.object3dManager.findMaterial(name);
return { tar: mat, acc, onChange: mat ? () => {
mat.setDirty && mat.setDirty({ frameFade: false });
} : undefined };
},
};
}
onRemove(viewer) {
super.onRemove(viewer);
viewer.object3dManager.removeEventListener('objectAdd', this._objectAdd);
viewer.object3dManager.removeEventListener('objectRemove', this._objectRemove);
viewer.object3dManager.removeEventListener('materialAdd', this._materialAdd);
viewer.object3dManager.removeEventListener('materialRemove', this._materialRemove);
delete viewer._animGetters;
}
fromJSON(data, meta) {
if (!super.fromJSON(data, meta))
return null;
// this.animation.setTarget(() => this._viewer)
return this;
}
}
AnimationObjectPlugin.PluginType = 'AnimationObjectPlugin';
__decorate([
serialize()
// @uiConfig()
], AnimationObjectPlugin.prototype, "animation", void 0);
//# sourceMappingURL=AnimationObjectPlugin.js.map