Browse Source

camera spin animation

David Sehnal 4 years ago
parent
commit
a8bf90a68b

+ 1 - 1
src/examples/basic-wrapper/index.ts

@@ -7,7 +7,7 @@
 import { EmptyLoci } from '../../mol-model/loci';
 import { StructureSelection } from '../../mol-model/structure';
 import { createPlugin, DefaultPluginSpec } from '../../mol-plugin';
-import { AnimateModelIndex } from '../../mol-plugin-state/animation/built-in';
+import { AnimateModelIndex } from '../../mol-plugin-state/animation/built-in/model-index';
 import { BuiltInTrajectoryFormat } from '../../mol-plugin-state/formats/trajectory';
 import { PluginCommands } from '../../mol-plugin/commands';
 import { PluginContext } from '../../mol-plugin/context';

+ 1 - 1
src/examples/proteopedia-wrapper/index.ts

@@ -7,7 +7,7 @@
 import * as ReactDOM from 'react-dom';
 import { Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
 import { createPlugin, DefaultPluginSpec } from '../../mol-plugin';
-import { AnimateModelIndex } from '../../mol-plugin-state/animation/built-in';
+import { AnimateModelIndex } from '../../mol-plugin-state/animation/built-in/model-index';
 import { createStructureRepresentationParams } from '../../mol-plugin-state/helpers/structure-representation-params';
 import { PluginStateObject, PluginStateObject as PSO } from '../../mol-plugin-state/objects';
 import { StateTransforms } from '../../mol-plugin-state/transforms';

+ 1 - 1
src/extensions/mp4-export/encoder.ts

@@ -1,6 +1,6 @@
 import * as HME from 'h264-mp4-encoder';
 import { canvasToBlob } from '../../mol-canvas3d/util';
-import { AnimateAssemblyUnwind } from '../../mol-plugin-state/animation/built-in';
+import { AnimateAssemblyUnwind } from '../../mol-plugin-state/animation/built-in/assembly-unwind';
 import { PluginContext } from '../../mol-plugin/context';
 
 export class Mp4Encoder {

+ 0 - 298
src/mol-plugin-state/animation/built-in.ts

@@ -1,298 +0,0 @@
-/**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { PluginStateAnimation } from './model';
-import { PluginStateObject } from '../objects';
-import { StateTransforms } from '../transforms';
-import { StateSelection, StateTransform } from '../../mol-state';
-import { PluginCommands } from '../../mol-plugin/commands';
-import { ParamDefinition as PD } from '../../mol-util/param-definition';
-import { PluginContext } from '../../mol-plugin/context';
-
-export const AnimateModelIndex = PluginStateAnimation.create({
-    name: 'built-in.animate-model-index',
-    display: { name: 'Animate Trajectory' },
-    params: () => ({
-        mode: PD.MappedStatic('palindrome', {
-            palindrome: PD.Group({ }),
-            loop: PD.Group({ }),
-            once: PD.Group({ direction: PD.Select('forward', [['forward', 'Forward'], ['backward', 'Backward']]) }, { isFlat: true })
-        }, { options: [['palindrome', 'Palindrome'], ['loop', 'Loop'], ['once', 'Once']] }),
-        maxFPS: PD.Numeric(15, { min: 1, max: 60, step: 1 })
-    }),
-    canApply(ctx) {
-        const state = ctx.state.data;
-        const models = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Model.ModelFromTrajectory));
-        for (const m of models) {
-            const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]);
-            if (parent && parent.obj && parent.obj.data.frameCount > 1) return { canApply: true };
-        }
-        return { canApply: false, reason: 'No trajectory to animate' };
-    },
-    initialState: () => ({} as { palindromeDirections?: { [id: string]: -1 | 1 | undefined } }),
-    async apply(animState, t, ctx) {
-        // limit fps
-        if (t.current > 0 && t.current - t.lastApplied < 1000 / ctx.params.maxFPS) {
-            return { kind: 'skip' };
-        }
-
-        const state = ctx.plugin.state.data;
-        const models = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Model.ModelFromTrajectory));
-
-        if (models.length === 0) {
-            // nothing more to do here
-            return { kind: 'finished' };
-        }
-
-        const update = state.build();
-
-        const params = ctx.params;
-        const palindromeDirections = animState.palindromeDirections || { };
-        let isEnd = false, allSingles = true;
-
-        for (const m of models) {
-            const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]);
-            if (!parent || !parent.obj) continue;
-            const traj = parent.obj;
-            if (traj.data.frameCount <= 1) continue;
-
-            update.to(m).update(old => {
-                const len = traj.data.frameCount;
-                if (len !== 1) {
-                    allSingles = false;
-                } else {
-                    return old;
-                }
-                let dir: -1 | 1 = 1;
-                if (params.mode.name === 'once') {
-                    dir = params.mode.params.direction === 'backward' ? -1 : 1;
-                    // if we are at start or end already, do nothing.
-                    if ((dir === -1 && old.modelIndex === 0) || (dir === 1 && old.modelIndex === len - 1)) {
-                        isEnd = true;
-                        return old;
-                    }
-                } else if (params.mode.name === 'palindrome') {
-                    if (old.modelIndex === 0) dir = 1;
-                    else if (old.modelIndex === len - 1) dir = -1;
-                    else dir = palindromeDirections[m.transform.ref] || 1;
-                }
-                palindromeDirections[m.transform.ref] = dir;
-
-                let modelIndex = (old.modelIndex + dir) % len;
-                if (modelIndex < 0) modelIndex += len;
-
-                isEnd = isEnd || (dir === -1 && modelIndex === 0) || (dir === 1 && modelIndex === len - 1);
-
-                return { modelIndex };
-            });
-        }
-
-        if (!allSingles) {
-            await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
-        }
-
-        if (allSingles || (params.mode.name === 'once' && isEnd)) return { kind: 'finished' };
-        if (params.mode.name === 'palindrome') return { kind: 'next', state: { palindromeDirections } };
-        return { kind: 'next', state: {} };
-    }
-});
-
-export const AnimateAssemblyUnwind = PluginStateAnimation.create({
-    name: 'built-in.animate-assembly-unwind',
-    display: { name: 'Unwind Assembly' },
-    params: (plugin: PluginContext) => {
-        const targets: [string, string][] = [['all', 'All']];
-        const structures = plugin.state.data.select(StateSelection.Generators.rootsOfType(PluginStateObject.Molecule.Structure));
-
-        for (const s of structures) {
-            targets.push([s.transform.ref, s.obj!.data.models[0].label]);
-        }
-
-        return {
-            durationInMs: PD.Numeric(3000, { min: 100, max: 10000, step: 100}),
-            playOnce: PD.Boolean(false),
-            target: PD.Select(targets[0][0], targets)
-        };
-    },
-    canApply(plugin) {
-        const state = plugin.state.data;
-        const root = StateTransform.RootRef;
-        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3D, root));
-        return { canApply: reprs.length > 0 };
-    },
-    initialState: () => ({ t: 0 }),
-    setup(params, plugin) {
-        const state = plugin.state.data;
-        const root = !params.target || params.target === 'all' ? StateTransform.RootRef : params.target;
-        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3D, root));
-
-        const update = state.build();
-        let changed = false;
-        for (const r of reprs) {
-            const unwinds = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D, r.transform.ref));
-            if (unwinds.length > 0) continue;
-
-            changed = true;
-            update.to(r)
-                .apply(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D, { t: 0 }, { tags: 'animate-assembly-unwind' });
-        }
-
-        if (!changed) return;
-
-        return update.commit({ doNotUpdateCurrent: true });
-    },
-    teardown(_, plugin) {
-        const state = plugin.state.data;
-        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3DState)
-            .withTag('animate-assembly-unwind'));
-        if (reprs.length === 0) return;
-
-        const update = state.build();
-        for (const r of reprs) update.delete(r.transform.ref);
-        return update.commit();
-    },
-    async apply(animState, t, ctx) {
-        const state = ctx.plugin.state.data;
-        const root = !ctx.params.target || ctx.params.target === 'all' ? StateTransform.RootRef : ctx.params.target;
-        const anims = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D, root));
-
-        if (anims.length === 0) {
-            return { kind: 'finished' };
-        }
-
-        const update = state.build();
-
-        const d = (t.current - t.lastApplied) / ctx.params.durationInMs;
-        let newTime = (animState.t + d), finished = false;
-        if (ctx.params.playOnce && newTime >= 1) {
-            finished = true;
-            newTime = 1;
-        } else {
-            newTime = newTime % 1;
-        }
-
-        for (const m of anims) {
-            update.to(m).update({ t: newTime });
-        }
-
-        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
-
-        if (finished) return { kind: 'finished' };
-        return { kind: 'next', state: { t: newTime } };
-    }
-});
-
-export const AnimateUnitsExplode = PluginStateAnimation.create({
-    name: 'built-in.animate-units-explode',
-    display: { name: 'Explode Units' },
-    params: () => ({
-        durationInMs: PD.Numeric(3000, { min: 100, max: 10000, step: 100})
-    }),
-    initialState: () => ({ t: 0 }),
-    async setup(_, plugin) {
-        const state = plugin.state.data;
-        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3D));
-
-        const update = state.build();
-        let changed = false;
-        for (const r of reprs) {
-            const explodes = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.ExplodeStructureRepresentation3D, r.transform.ref));
-            if (explodes.length > 0) continue;
-
-            changed = true;
-            update.to(r.transform.ref)
-                .apply(StateTransforms.Representation.ExplodeStructureRepresentation3D, { t: 0 }, { tags: 'animate-units-explode' });
-        }
-
-        if (!changed) return;
-
-        return update.commit({ doNotUpdateCurrent: true });
-    },
-    teardown(_, plugin) {
-        const state = plugin.state.data;
-        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3DState)
-            .withTag('animate-units-explode'));
-        if (reprs.length === 0) return;
-
-        const update = state.build();
-        for (const r of reprs) update.delete(r.transform.ref);
-        return update.commit();
-    },
-    async apply(animState, t, ctx) {
-        const state = ctx.plugin.state.data;
-        const anims = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.ExplodeStructureRepresentation3D));
-
-        if (anims.length === 0) {
-            return { kind: 'finished' };
-        }
-
-        const update = state.build();
-
-        const d = (t.current - t.lastApplied) / ctx.params.durationInMs;
-        const newTime = (animState.t + d) % 1;
-
-        for (const m of anims) {
-            update.to(m).update({ t: newTime });
-        }
-
-        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
-
-        return { kind: 'next', state: { t: newTime } };
-    }
-});
-
-export const AnimateStateInterpolation = PluginStateAnimation.create({
-    name: 'built-in.animate-state-interpolation',
-    display: { name: 'Animate State Interpolation' },
-    params: () => ({
-        transtionDurationInMs: PD.Numeric(2000, { min: 100, max: 30000, step: 10 })
-    }),
-    canApply(plugin) {
-        return { canApply: plugin.managers.snapshot.state.entries.size > 1 };
-    },
-    initialState: () => ({ }),
-    async apply(animState, t, ctx) {
-
-        const snapshots = ctx.plugin.managers.snapshot.state.entries;
-        if (snapshots.size < 2) return { kind: 'finished' };
-
-        // const totalTime = (snapshots.size - 1) * ctx.params.transtionDurationInMs;
-        const currentT = (t.current % ctx.params.transtionDurationInMs) / ctx.params.transtionDurationInMs;
-
-        let srcIndex = Math.floor(t.current / ctx.params.transtionDurationInMs) % snapshots.size;
-        let tarIndex = Math.ceil(t.current / ctx.params.transtionDurationInMs);
-        if (tarIndex === srcIndex) tarIndex++;
-        tarIndex = tarIndex % snapshots.size;
-
-        const _src = snapshots.get(srcIndex)!.snapshot, _tar = snapshots.get(tarIndex)!.snapshot;
-
-        if (!_src.data || !_tar.data) return { kind: 'skip' };
-
-        const src = _src.data.tree.transforms, tar = _tar.data.tree.transforms;
-
-        const state = ctx.plugin.state.data;
-        const update = state.build();
-
-        for (const s of src) {
-            for (const t of tar) {
-                if (t.ref !== s.ref) continue;
-                if (t.version === s.version) continue;
-
-                const e = StateTransform.fromJSON(s), f = StateTransform.fromJSON(t);
-
-                if (!e.transformer.definition.interpolate) {
-                    update.to(s.ref).update(currentT <= 0.5 ? e.params : f.params);
-                } else {
-                    update.to(s.ref).update(e.transformer.definition.interpolate(e.params, f.params, currentT, ctx.plugin));
-                }
-            }
-        }
-
-        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
-
-        return { kind: 'next', state: { } };
-    }
-});

+ 98 - 0
src/mol-plugin-state/animation/built-in/assembly-unwind.ts

@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginStateAnimation } from '../model';
+import { PluginStateObject } from '../../objects';
+import { StateTransforms } from '../../transforms';
+import { StateSelection, StateTransform } from '../../../mol-state';
+import { PluginCommands } from '../../../mol-plugin/commands';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { PluginContext } from '../../../mol-plugin/context';
+
+export const AnimateAssemblyUnwind = PluginStateAnimation.create({
+    name: 'built-in.animate-assembly-unwind',
+    display: { name: 'Unwind Assembly' },
+    params: (plugin: PluginContext) => {
+        const targets: [string, string][] = [['all', 'All']];
+        const structures = plugin.state.data.select(StateSelection.Generators.rootsOfType(PluginStateObject.Molecule.Structure));
+
+        for (const s of structures) {
+            targets.push([s.transform.ref, s.obj!.data.models[0].label]);
+        }
+
+        return {
+            durationInMs: PD.Numeric(3000, { min: 100, max: 10000, step: 100}),
+            playOnce: PD.Boolean(false),
+            target: PD.Select(targets[0][0], targets)
+        };
+    },
+    canApply(plugin) {
+        const state = plugin.state.data;
+        const root = StateTransform.RootRef;
+        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3D, root));
+        return { canApply: reprs.length > 0 };
+    },
+    initialState: () => ({ t: 0 }),
+    setup(params, plugin) {
+        const state = plugin.state.data;
+        const root = !params.target || params.target === 'all' ? StateTransform.RootRef : params.target;
+        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3D, root));
+
+        const update = state.build();
+        let changed = false;
+        for (const r of reprs) {
+            const unwinds = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D, r.transform.ref));
+            if (unwinds.length > 0) continue;
+
+            changed = true;
+            update.to(r)
+                .apply(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D, { t: 0 }, { tags: 'animate-assembly-unwind' });
+        }
+
+        if (!changed) return;
+
+        return update.commit({ doNotUpdateCurrent: true });
+    },
+    teardown(_, plugin) {
+        const state = plugin.state.data;
+        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3DState)
+            .withTag('animate-assembly-unwind'));
+        if (reprs.length === 0) return;
+
+        const update = state.build();
+        for (const r of reprs) update.delete(r.transform.ref);
+        return update.commit();
+    },
+    async apply(animState, t, ctx) {
+        const state = ctx.plugin.state.data;
+        const root = !ctx.params.target || ctx.params.target === 'all' ? StateTransform.RootRef : ctx.params.target;
+        const anims = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D, root));
+
+        if (anims.length === 0) {
+            return { kind: 'finished' };
+        }
+
+        const update = state.build();
+
+        const d = (t.current - t.lastApplied) / ctx.params.durationInMs;
+        let newTime = (animState.t + d), finished = false;
+        if (ctx.params.playOnce && newTime >= 1) {
+            finished = true;
+            newTime = 1;
+        } else {
+            newTime = newTime % 1;
+        }
+
+        for (const m of anims) {
+            update.to(m).update({ t: newTime });
+        }
+
+        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+
+        if (finished) return { kind: 'finished' };
+        return { kind: 'next', state: { t: newTime } };
+    }
+});

+ 52 - 0
src/mol-plugin-state/animation/built-in/camera-spin.ts

@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Camera } from '../../../mol-canvas3d/camera';
+import { clamp } from '../../../mol-math/interpolate';
+import { Quat, Vec3 } from '../../../mol-math/linear-algebra/3d';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { PluginStateAnimation } from '../model';
+
+const _dir = Vec3(), _axis = Vec3(), _rot = Quat();
+
+export const AnimateCameraSpin = PluginStateAnimation.create({
+    name: 'built-in.animate-camera-spin',
+    display: { name: 'Camera Spin' },
+    params: () => ({
+        durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
+        direction: PD.Select<'cw' | 'ccw'>('cw', [['cw', 'Clockwise'], ['ccw', 'Counter Clockwise']], { cycle: true }),
+        skipLastFrame: PD.Boolean(true)
+    }),
+    initialState: () => ({ }),
+    async apply(animState: { snapshot: Camera.Snapshot }, t, ctx) {
+        if (t.current === 0) {
+            return { kind: 'next', state: animState };
+        } else if (ctx.params.skipLastFrame && t.current >= ctx.params.durationInMs) {
+            return { kind: 'finished' };
+        }
+
+        const camera = ctx.plugin.canvas3d?.camera!;
+        if (camera.state.radiusMax < 0.0001) {
+            return { kind: 'finished' };
+        }
+
+        const delta = clamp((t.current - t.lastApplied) / ctx.params.durationInMs, 0, 1);
+        const angle = 2 * Math.PI * delta * (ctx.params.direction === 'ccw' ? -1 : 1);
+
+        Vec3.sub(_dir, camera.position, camera.target);
+        Vec3.normalize(_axis, camera.up);
+        Quat.setAxisAngle(_rot, _axis, angle);
+        Vec3.transformQuat(_dir, _dir, _rot);
+        const position = Vec3.add(Vec3(), camera.target, _dir);
+        ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { position }, durationMs: 0 });
+
+        if (t.current >= ctx.params.durationInMs) {
+            return { kind: 'finished' };
+        }
+
+        return { kind: 'next', state: animState };
+    }
+});

+ 71 - 0
src/mol-plugin-state/animation/built-in/explode-units.ts

@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommands } from '../../../mol-plugin/commands';
+import { StateSelection } from '../../../mol-state';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { PluginStateObject } from '../../objects';
+import { StateTransforms } from '../../transforms';
+import { PluginStateAnimation } from '../model';
+
+export const AnimateUnitsExplode = PluginStateAnimation.create({
+    name: 'built-in.animate-units-explode',
+    display: { name: 'Explode Units' },
+    params: () => ({
+        durationInMs: PD.Numeric(3000, { min: 100, max: 10000, step: 100})
+    }),
+    initialState: () => ({ t: 0 }),
+    async setup(_, plugin) {
+        const state = plugin.state.data;
+        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3D));
+
+        const update = state.build();
+        let changed = false;
+        for (const r of reprs) {
+            const explodes = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.ExplodeStructureRepresentation3D, r.transform.ref));
+            if (explodes.length > 0) continue;
+
+            changed = true;
+            update.to(r.transform.ref)
+                .apply(StateTransforms.Representation.ExplodeStructureRepresentation3D, { t: 0 }, { tags: 'animate-units-explode' });
+        }
+
+        if (!changed) return;
+
+        return update.commit({ doNotUpdateCurrent: true });
+    },
+    teardown(_, plugin) {
+        const state = plugin.state.data;
+        const reprs = state.select(StateSelection.Generators.ofType(PluginStateObject.Molecule.Structure.Representation3DState)
+            .withTag('animate-units-explode'));
+        if (reprs.length === 0) return;
+
+        const update = state.build();
+        for (const r of reprs) update.delete(r.transform.ref);
+        return update.commit();
+    },
+    async apply(animState, t, ctx) {
+        const state = ctx.plugin.state.data;
+        const anims = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.ExplodeStructureRepresentation3D));
+
+        if (anims.length === 0) {
+            return { kind: 'finished' };
+        }
+
+        const update = state.build();
+
+        const d = (t.current - t.lastApplied) / ctx.params.durationInMs;
+        const newTime = (animState.t + d) % 1;
+
+        for (const m of anims) {
+            update.to(m).update({ t: newTime });
+        }
+
+        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+
+        return { kind: 'next', state: { t: newTime } };
+    }
+});

+ 100 - 0
src/mol-plugin-state/animation/built-in/model-index.ts

@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommands } from '../../../mol-plugin/commands';
+import { StateSelection } from '../../../mol-state';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { PluginStateObject } from '../../objects';
+import { StateTransforms } from '../../transforms';
+import { PluginStateAnimation } from '../model';
+
+export const AnimateModelIndex = PluginStateAnimation.create({
+    name: 'built-in.animate-model-index',
+    display: { name: 'Animate Trajectory' },
+    params: () => ({
+        mode: PD.MappedStatic('palindrome', {
+            palindrome: PD.Group({ }),
+            loop: PD.Group({ }),
+            once: PD.Group({ direction: PD.Select('forward', [['forward', 'Forward'], ['backward', 'Backward']]) }, { isFlat: true })
+        }, { options: [['palindrome', 'Palindrome'], ['loop', 'Loop'], ['once', 'Once']] }),
+        maxFPS: PD.Numeric(15, { min: 1, max: 60, step: 1 })
+    }),
+    canApply(ctx) {
+        const state = ctx.state.data;
+        const models = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Model.ModelFromTrajectory));
+        for (const m of models) {
+            const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]);
+            if (parent && parent.obj && parent.obj.data.frameCount > 1) return { canApply: true };
+        }
+        return { canApply: false, reason: 'No trajectory to animate' };
+    },
+    initialState: () => ({} as { palindromeDirections?: { [id: string]: -1 | 1 | undefined } }),
+    async apply(animState, t, ctx) {
+        // limit fps
+        if (t.current > 0 && t.current - t.lastApplied < 1000 / ctx.params.maxFPS) {
+            return { kind: 'skip' };
+        }
+
+        const state = ctx.plugin.state.data;
+        const models = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Model.ModelFromTrajectory));
+
+        if (models.length === 0) {
+            // nothing more to do here
+            return { kind: 'finished' };
+        }
+
+        const update = state.build();
+
+        const params = ctx.params;
+        const palindromeDirections = animState.palindromeDirections || { };
+        let isEnd = false, allSingles = true;
+
+        for (const m of models) {
+            const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]);
+            if (!parent || !parent.obj) continue;
+            const traj = parent.obj;
+            if (traj.data.frameCount <= 1) continue;
+
+            update.to(m).update(old => {
+                const len = traj.data.frameCount;
+                if (len !== 1) {
+                    allSingles = false;
+                } else {
+                    return old;
+                }
+                let dir: -1 | 1 = 1;
+                if (params.mode.name === 'once') {
+                    dir = params.mode.params.direction === 'backward' ? -1 : 1;
+                    // if we are at start or end already, do nothing.
+                    if ((dir === -1 && old.modelIndex === 0) || (dir === 1 && old.modelIndex === len - 1)) {
+                        isEnd = true;
+                        return old;
+                    }
+                } else if (params.mode.name === 'palindrome') {
+                    if (old.modelIndex === 0) dir = 1;
+                    else if (old.modelIndex === len - 1) dir = -1;
+                    else dir = palindromeDirections[m.transform.ref] || 1;
+                }
+                palindromeDirections[m.transform.ref] = dir;
+
+                let modelIndex = (old.modelIndex + dir) % len;
+                if (modelIndex < 0) modelIndex += len;
+
+                isEnd = isEnd || (dir === -1 && modelIndex === 0) || (dir === 1 && modelIndex === len - 1);
+
+                return { modelIndex };
+            });
+        }
+
+        if (!allSingles) {
+            await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+        }
+
+        if (allSingles || (params.mode.name === 'once' && isEnd)) return { kind: 'finished' };
+        if (params.mode.name === 'palindrome') return { kind: 'next', state: { palindromeDirections } };
+        return { kind: 'next', state: {} };
+    }
+});

+ 62 - 0
src/mol-plugin-state/animation/built-in/state-interpolation.ts

@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommands } from '../../../mol-plugin/commands';
+import { StateTransform } from '../../../mol-state';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { PluginStateAnimation } from '../model';
+
+export const AnimateStateInterpolation = PluginStateAnimation.create({
+    name: 'built-in.animate-state-interpolation',
+    display: { name: 'Animate State Interpolation' },
+    params: () => ({
+        transtionDurationInMs: PD.Numeric(2000, { min: 100, max: 30000, step: 10 })
+    }),
+    canApply(plugin) {
+        return { canApply: plugin.managers.snapshot.state.entries.size > 1 };
+    },
+    initialState: () => ({ }),
+    async apply(animState, t, ctx) {
+
+        const snapshots = ctx.plugin.managers.snapshot.state.entries;
+        if (snapshots.size < 2) return { kind: 'finished' };
+
+        const currentT = (t.current % ctx.params.transtionDurationInMs) / ctx.params.transtionDurationInMs;
+
+        let srcIndex = Math.floor(t.current / ctx.params.transtionDurationInMs) % snapshots.size;
+        let tarIndex = Math.ceil(t.current / ctx.params.transtionDurationInMs);
+        if (tarIndex === srcIndex) tarIndex++;
+        tarIndex = tarIndex % snapshots.size;
+
+        const _src = snapshots.get(srcIndex)!.snapshot, _tar = snapshots.get(tarIndex)!.snapshot;
+
+        if (!_src.data || !_tar.data) return { kind: 'skip' };
+
+        const src = _src.data.tree.transforms, tar = _tar.data.tree.transforms;
+
+        const state = ctx.plugin.state.data;
+        const update = state.build();
+
+        for (const s of src) {
+            for (const t of tar) {
+                if (t.ref !== s.ref) continue;
+                if (t.version === s.version) continue;
+
+                const e = StateTransform.fromJSON(s), f = StateTransform.fromJSON(t);
+
+                if (!e.transformer.definition.interpolate) {
+                    update.to(s.ref).update(currentT <= 0.5 ? e.params : f.params);
+                } else {
+                    update.to(s.ref).update(e.transformer.definition.interpolate(e.params, f.params, currentT, ctx.plugin));
+                }
+            }
+        }
+
+        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+
+        return { kind: 'next', state: { } };
+    }
+});

+ 2 - 2
src/mol-plugin/animation-loop.ts

@@ -11,9 +11,9 @@ export class PluginAnimationLoop {
     private currentFrame: any = void 0;
     private _isAnimating = false;
 
-    tick(t: number, options?: { isSynchronous?: boolean, manualDraw?: boolean }) {
+    async tick(t: number, options?: { isSynchronous?: boolean, manualDraw?: boolean }) {
+        await this.plugin.managers.animation.tick(t, options?.isSynchronous);
         this.plugin.canvas3d?.tick(t as now.Timestamp, options);
-        return this.plugin.managers.animation.tick(t, options?.isSynchronous);
     }
 
     private frame = () => {

+ 4 - 3
src/mol-plugin/index.ts

@@ -8,7 +8,6 @@
 import * as React from 'react';
 import * as ReactDOM from 'react-dom';
 import { StateActions } from '../mol-plugin-state/actions';
-import { AnimateAssemblyUnwind, AnimateModelIndex, AnimateStateInterpolation, AnimateUnitsExplode } from '../mol-plugin-state/animation/built-in';
 import { StateTransforms } from '../mol-plugin-state/transforms';
 import { VolumeStreamingCustomControls } from '../mol-plugin-ui/custom/volume';
 import { Plugin } from '../mol-plugin-ui/plugin';
@@ -18,6 +17,9 @@ import { BoxifyVolumeStreaming, CreateVolumeStreamingBehavior, InitVolumeStreami
 import { PluginContext } from './context';
 import { PluginSpec } from './spec';
 import { AssignColorVolume } from '../mol-plugin-state/actions/volume';
+import { AnimateModelIndex } from '../mol-plugin-state/animation/built-in/model-index';
+import { AnimateAssemblyUnwind } from '../mol-plugin-state/animation/built-in/assembly-unwind';
+import { AnimateCameraSpin } from '../mol-plugin-state/animation/built-in/camera-spin';
 
 export const DefaultPluginSpec: PluginSpec = {
     actions: [
@@ -87,8 +89,7 @@ export const DefaultPluginSpec: PluginSpec = {
     animations: [
         AnimateModelIndex,
         AnimateAssemblyUnwind,
-        AnimateUnitsExplode,
-        AnimateStateInterpolation
+        AnimateCameraSpin
     ]
 };