Bladeren bron

Merge branch 'master' of https://github.com/molstar/molstar into wboit

Alexander Rose 4 jaren geleden
bovenliggende
commit
1f6e928d78
42 gewijzigde bestanden met toevoegingen van 1773 en 648 verwijderingen
  1. 6 1
      package-lock.json
  2. 2 1
      package.json
  3. 3 1
      src/apps/viewer/index.ts
  4. 1 33
      src/examples/alpha-orbitals/controls.tsx
  5. 1 1
      src/examples/basic-wrapper/index.ts
  6. 1 1
      src/examples/proteopedia-wrapper/index.ts
  7. 144 0
      src/extensions/mp4-export/controls.ts
  8. 101 0
      src/extensions/mp4-export/encoder.ts
  9. 30 0
      src/extensions/mp4-export/index.ts
  10. 124 0
      src/extensions/mp4-export/ui.tsx
  11. 4 0
      src/mol-canvas3d/camera.ts
  12. 39 5
      src/mol-canvas3d/canvas3d.ts
  13. 9 3
      src/mol-canvas3d/controls/trackball.ts
  14. 15 8
      src/mol-canvas3d/passes/image.ts
  15. 2 0
      src/mol-io/writer/mol/encoder.ts
  16. 15 13
      src/mol-io/writer/mol2/encoder.ts
  17. 0 298
      src/mol-plugin-state/animation/built-in.ts
  18. 99 0
      src/mol-plugin-state/animation/built-in/assembly-unwind.ts
  19. 59 0
      src/mol-plugin-state/animation/built-in/camera-spin.ts
  20. 71 0
      src/mol-plugin-state/animation/built-in/explode-units.ts
  21. 100 0
      src/mol-plugin-state/animation/built-in/model-index.ts
  22. 62 0
      src/mol-plugin-state/animation/built-in/state-interpolation.ts
  23. 21 2
      src/mol-plugin-state/animation/model.ts
  24. 44 22
      src/mol-plugin-state/manager/animation.ts
  25. 2 1
      src/mol-plugin-ui/controls/common.tsx
  26. 8 0
      src/mol-plugin-ui/controls/icons.tsx
  27. 324 0
      src/mol-plugin-ui/controls/screenshot.tsx
  28. 38 0
      src/mol-plugin-ui/hooks/use-behavior.ts
  29. 2 1
      src/mol-plugin-ui/plugin.tsx
  30. 20 11
      src/mol-plugin-ui/skin/base/components/controls.scss
  31. 28 55
      src/mol-plugin-ui/skin/base/components/tasks.scss
  32. 23 1
      src/mol-plugin-ui/task.tsx
  33. 66 101
      src/mol-plugin-ui/viewport/screenshot.tsx
  34. 47 0
      src/mol-plugin/animation-loop.ts
  35. 4 2
      src/mol-plugin/context.ts
  36. 5 4
      src/mol-plugin/index.ts
  37. 2 1
      src/mol-plugin/spec.ts
  38. 9 4
      src/mol-plugin/util/task-manager.ts
  39. 236 76
      src/mol-plugin/util/viewport-screenshot.ts
  40. 2 1
      src/mol-theme/label.ts
  41. 3 0
      src/servers/model/CHANGELOG.md
  42. 1 1
      src/servers/model/version.ts

+ 6 - 1
package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "1.1.31",
+  "version": "1.1.32",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -6957,6 +6957,11 @@
       "dev": true,
       "optional": true
     },
+    "h264-mp4-encoder": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/h264-mp4-encoder/-/h264-mp4-encoder-1.0.12.tgz",
+      "integrity": "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ=="
+    },
     "har-schema": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "1.1.31",
+  "version": "1.1.32",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -135,6 +135,7 @@
     "compression": "^1.7.4",
     "cors": "^2.8.5",
     "express": "^4.17.1",
+    "h264-mp4-encoder": "^1.0.12",
     "immer": "^7.0.9",
     "immutable": "^3.8.2",
     "node-fetch": "^2.6.0",

+ 3 - 1
src/apps/viewer/index.ts

@@ -34,6 +34,7 @@ import { StateObjectSelector } from '../../mol-state';
 import { PluginStateObject } from '../../mol-plugin-state/objects';
 import { StateTransforms } from '../../mol-plugin-state/transforms';
 import { createVolumeRepresentationParams } from '../../mol-plugin-state/helpers/volume-representation-params';
+import { Mp4Export } from '../../extensions/mp4-export';
 
 require('mol-plugin-ui/skin/light.scss');
 
@@ -51,7 +52,8 @@ const Extensions = {
     'rcsb-assembly-symmetry': PluginSpec.Behavior(RCSBAssemblySymmetry),
     'rcsb-validation-report': PluginSpec.Behavior(RCSBValidationReport),
     'anvil-membrane-orientation': PluginSpec.Behavior(ANVILMembraneOrientation),
-    'g3d': PluginSpec.Behavior(G3DFormat)
+    'g3d': PluginSpec.Behavior(G3DFormat),
+    'mp4-export': PluginSpec.Behavior(Mp4Export)
 };
 
 const DefaultViewerOptions = {

+ 1 - 33
src/examples/alpha-orbitals/controls.tsx

@@ -5,10 +5,10 @@
  */
 
 import * as React from 'react';
-import { useEffect, useState } from 'react';
 import * as ReactDOM from 'react-dom';
 import { AlphaOrbitalsExample } from '.';
 import { ParameterControls } from '../../mol-plugin-ui/controls/parameters';
+import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
 import { PluginContextContainer } from '../../mol-plugin-ui/plugin';
 
 export function mountControls(orbitals: AlphaOrbitalsExample, parent: Element) {
@@ -22,36 +22,4 @@ function Controls({ orbitals }: { orbitals: AlphaOrbitalsExample }) {
     const values = useBehavior(orbitals.state);
 
     return <ParameterControls params={params as any} values={values} onChangeValues={(vs: any) => orbitals.state.next(vs)} />;
-}
-
-
-interface Behavior<T> {
-    value: T;
-    subscribe(f: (v: T) => void): { unsubscribe(): void };
-}
-
-export function useBehavior<T>(s: Behavior<T>): T;
-// eslint-disable-next-line
-export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined;
-// eslint-disable-next-line
-export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined {
-    const [value, setValue] = useState(s?.value);
-
-    useEffect(() => {
-        if (!s) return;
-        let fst = true;
-        const sub = s.subscribe((v) => {
-            if (fst) {
-                fst = false;
-                if (v !== value) setValue(v);
-            } else setValue(v);
-        });
-
-        return () => {
-            sub.unsubscribe();
-        };
-        // eslint-disable-next-line
-    }, [s]);
-
-    return value;
 }

+ 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';

+ 144 - 0
src/extensions/mp4-export/controls.ts

@@ -0,0 +1,144 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { debounceTime } from 'rxjs/operators';
+import { PluginStateAnimation } from '../../mol-plugin-state/animation/model';
+import { PluginComponent } from '../../mol-plugin-state/component';
+import { PluginContext } from '../../mol-plugin/context';
+import { Task } from '../../mol-task';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { encodeMp4Animation } from './encoder';
+
+export interface Mp4AnimationInfo {
+    width: number,
+    height: number
+}
+
+export const Mp4AnimationParams = {
+    quantization: PD.Numeric(18, { min: 10, max: 51 }, { description: 'Lower is better, but slower.' })
+};
+
+export class Mp4Controls extends PluginComponent {
+    private currentNames = new Set<string>();
+    private animations: PluginStateAnimation[] = [];
+
+    readonly behaviors = {
+        animations: this.ev.behavior<PD.Params>({ }),
+        current: this.ev.behavior<{ anim: PluginStateAnimation, params: PD.Params, values: any } | undefined>(void 0),
+        canApply: this.ev.behavior<PluginStateAnimation.CanApply>({ canApply: false }),
+        info: this.ev.behavior<Mp4AnimationInfo>({ width: 0, height: 0 }),
+        params: this.ev.behavior<PD.Values<typeof Mp4AnimationParams>>(PD.getDefaultValues(Mp4AnimationParams))
+    }
+
+    setCurrent(name?: string) {
+        const anim = this.animations.find(a => a.name === name);
+        if (!anim) {
+            this.behaviors.current.next(anim);
+            return;
+        }
+
+        const params = anim.params(this.plugin) as PD.Params;
+        const values = PD.getDefaultValues(params);
+
+        this.behaviors.current.next({ anim, params, values });
+        this.behaviors.canApply.next(anim.canApply?.(this.plugin) ?? { canApply: true });
+    }
+
+    setCurrentParams(values: any) {
+        this.behaviors.current.next({ ...this.behaviors.current.value!, values });
+    }
+
+    get current() {
+        return this.behaviors.current.value;
+    }
+
+    render() {
+        const task = Task.create('Export Animation', async ctx => {
+            try {
+                const resolution = this.plugin.helpers.viewportScreenshot?.getSizeAndViewport()!;
+                const anim = this.current!;
+                const movie = await encodeMp4Animation(this.plugin, ctx, {
+                    animation: {
+                        definition: anim.anim,
+                        params: anim.values,
+                    },
+                    ...resolution,
+                    quantizationParameter: this.behaviors.params.value.quantization,
+                    pass: this.plugin.helpers.viewportScreenshot?.imagePass!,
+                });
+
+                const filename = anim.anim.display.name.toLowerCase().replace(/\s/g, '-').replace(/[^a-z0-9_\-]/g, '');
+                return { movie, filename: `${this.plugin.helpers.viewportScreenshot?.getFilename('')}_${filename}.mp4` };
+            } catch (e) {
+                this.plugin.log.error('' + e);
+                throw e;
+            }
+        });
+
+        return this.plugin.runTask(task, { useOverlay: true });
+    }
+
+    private get manager() {
+        return this.plugin.managers.animation;
+    }
+
+    private syncInfo() {
+        const helper = this.plugin.helpers.viewportScreenshot;
+        const size = helper?.getSizeAndViewport();
+        if (!size) return;
+
+        this.behaviors.info.next({ width: size.viewport.width, height: size.viewport.height });
+    }
+
+    private sync() {
+        const animations = this.manager.animations.filter(a => a.isExportable);
+
+        const hasAll = animations.every(a => this.currentNames.has(a.name));
+        if (hasAll && this.currentNames.size === animations.length) {
+            return;
+        }
+
+        const params = {
+            current: PD.Select(animations[0]?.name,
+                animations.map(a => [a.name, a.display.name] as [string, string]),
+                { label: 'Animation' })
+        };
+
+        const current = this.behaviors.current.value;
+        const hasCurrent = !!animations.find(a => a.name === current?.anim.name);
+
+        this.animations = animations;
+        if (!hasCurrent) {
+            this.setCurrent(animations[0]?.name);
+        }
+        this.behaviors.animations.next(params);
+    }
+
+    private init() {
+        this.subscribe(this.plugin.managers.animation.events.updated.pipe(debounceTime(16)), () => {
+            this.sync();
+        });
+
+        this.subscribe(this.plugin.canvas3d?.resized!, () => this.syncInfo());
+        this.subscribe(this.plugin.helpers.viewportScreenshot?.events.previewed!, () => this.syncInfo());
+
+        this.subscribe(this.plugin.behaviors.state.isBusy, b => {
+            const anim = this.current;
+            if (!b && anim) {
+                this.behaviors.canApply.next(anim.anim.canApply?.(this.plugin) ?? { canApply: true });
+            }
+        });
+
+        this.sync();
+        this.syncInfo();
+    }
+
+    constructor(private plugin: PluginContext) {
+        super();
+
+        this.init();
+    }
+}

+ 101 - 0
src/extensions/mp4-export/encoder.ts

@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as HME from 'h264-mp4-encoder';
+import { Viewport } from '../../mol-canvas3d/camera/util';
+import { ImagePass } from '../../mol-canvas3d/passes/image';
+import { PluginStateAnimation } from '../../mol-plugin-state/animation/model';
+import { PluginContext } from '../../mol-plugin/context';
+import { RuntimeContext } from '../../mol-task';
+import { Color } from '../../mol-util/color';
+
+export interface Mp4EncoderParams<A extends PluginStateAnimation = PluginStateAnimation> {
+    pass: ImagePass,
+    customBackground?: Color,
+    animation: PluginStateAnimation.Instance<A>,
+    width: number,
+    height: number,
+    viewport: Viewport,
+    /** default is 30 */
+    fps?: number,
+    /** Number from 10 (best quality, slowest) to 51 (worst, fastest) */
+    quantizationParameter?: number
+}
+
+export async function encodeMp4Animation<A extends PluginStateAnimation>(plugin: PluginContext, ctx: RuntimeContext, params: Mp4EncoderParams<A>) {
+    await ctx.update({ message: 'Initializing...', isIndeterminate: true });
+
+    validateViewport(params);
+    const durationMs = PluginStateAnimation.getDuration(plugin, params.animation);
+    if (durationMs === void 0) {
+        throw new Error('The animation does not have the duration specified.');
+    }
+
+    const encoder = await HME.createH264MP4Encoder();
+
+    const { width, height } = params;
+    let vw = params.viewport.width, vh = params.viewport.height;
+
+    // dimensions must be a multiple of 2
+    if (vw % 2 !== 0) vw -= 1;
+    if (vh % 2 !== 0) vh -= 1;
+
+    const normalizedViewport: Viewport = { ...params.viewport, width: vw, height: vh };
+
+    encoder.width = vw;
+    encoder.height = vh;
+    if (params.quantizationParameter) encoder.quantizationParameter = params.quantizationParameter;
+    if (params.fps) encoder.frameRate = params.fps;
+    encoder.initialize();
+
+    const loop = plugin.animationLoop;
+    const originalBackground = params.customBackground !== void 0 ? plugin.canvas3d?.props.renderer.backgroundColor : void 0;
+    let stoppedAnimation = true, finalized = false;
+
+    try {
+        loop.stop();
+        loop.resetTime(0);
+        plugin.canvas3d?.setProps({ renderer: { backgroundColor: params.customBackground } }, true);
+
+        const fps = encoder.frameRate;
+        const N = Math.ceil(durationMs / 1000 * fps);
+        const dt = durationMs / N;
+
+        await ctx.update({ message: 'Rendering...', isIndeterminate: false, current: 0, max: N + 1 });
+
+        await plugin.managers.animation.play(params.animation.definition, params.animation.params);
+        stoppedAnimation = false;
+        for (let i = 0; i <= N; i++) {
+            await loop.tick(i * dt, { isSynchronous: true, manualDraw: true });
+
+            const image = params.pass.getImageData(width, height, normalizedViewport);
+            encoder.addFrameRgba(image.data);
+
+            if (ctx.shouldUpdate) {
+                await ctx.update({ current: i + 1 });
+            }
+        }
+        await ctx.update({ message: 'Applying finishing touches...', isIndeterminate: true });
+        await plugin.managers.animation.stop();
+        stoppedAnimation = true;
+        encoder.finalize();
+        finalized = true;
+        return encoder.FS.readFile(encoder.outputFilename);
+    } finally {
+        if (finalized) encoder.delete();
+        if (originalBackground !== void 0) {
+            plugin.canvas3d?.setProps({ renderer: { backgroundColor: originalBackground } });
+        }
+        if (!stoppedAnimation) await plugin.managers.animation.stop();
+        loop.start();
+    }
+}
+
+function validateViewport(params: Mp4EncoderParams) {
+    if (params.viewport.x + params.viewport.width > params.width || params.viewport.y + params.viewport.height > params.height) {
+        throw new Error('Viewport exceeds the canvas dimensions.');
+    }
+}

+ 30 - 0
src/extensions/mp4-export/index.ts

@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginBehavior } from '../../mol-plugin/behavior/behavior';
+import { Mp4EncoderUI } from './ui';
+
+export const Mp4Export = PluginBehavior.create<{ }>({
+    name: 'extension-mp4-export',
+    category: 'misc',
+    display: {
+        name: 'MP4 Animation Export'
+    },
+    ctor: class extends PluginBehavior.Handler<{ }> {
+        register(): void {
+            this.ctx.customStructureControls.set('mp4-export', Mp4EncoderUI as any);
+        }
+
+        update() {
+            return false;
+        }
+
+        unregister() {
+            this.ctx.customStructureControls.delete('mp4-export');
+        }
+    },
+    params: () => ({ })
+});

+ 124 - 0
src/extensions/mp4-export/ui.tsx

@@ -0,0 +1,124 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import React from 'react';
+import { merge } from 'rxjs';
+import { debounceTime } from 'rxjs/operators';
+import { CollapsableControls, CollapsableState } from '../../mol-plugin-ui/base';
+import { Button } from '../../mol-plugin-ui/controls/common';
+import { CameraOutlinedSvg, GetAppSvg, Icon, SubscriptionsOutlinedSvg } from '../../mol-plugin-ui/controls/icons';
+import { ParameterControls } from '../../mol-plugin-ui/controls/parameters';
+import { download } from '../../mol-util/download';
+import { Mp4AnimationParams, Mp4Controls } from './controls';
+
+interface State {
+    busy?: boolean,
+    data?: { movie: Uint8Array, filename: string };
+}
+
+export class Mp4EncoderUI extends CollapsableControls<{}, State> {
+    private _controls: Mp4Controls | undefined;
+
+    get controls() {
+        return this._controls || (this._controls = new Mp4Controls(this.plugin));
+    }
+
+    protected defaultState(): State & CollapsableState {
+        return {
+            header: 'Export Animation',
+            isCollapsed: true,
+            brand: { accent: 'cyan', svg: SubscriptionsOutlinedSvg }
+        };
+    }
+
+    private downloadControls() {
+        return <>
+            <div className='msp-control-offset msp-help-text'>
+                <div className='msp-help-description' style={{ textAlign: 'center' }}>
+                    Rendering successful!
+                </div>
+            </div>
+            <Button icon={GetAppSvg} onClick={this.save} style={{ marginTop: 1 }}>Save Animation</Button>
+            <Button onClick={() => this.setState({ data: void 0 })} style={{ marginTop: 6 }}>Clear</Button>
+        </>;
+    }
+
+    protected renderControls(): JSX.Element | null {
+        if (this.state.data) {
+            return this.downloadControls();
+        }
+
+        const ctrl = this.controls;
+        const current = ctrl.behaviors.current.value;
+        const info = ctrl.behaviors.info.value;
+        const canApply = ctrl.behaviors.canApply.value;
+        return <>
+            <ParameterControls
+                params={ctrl.behaviors.animations.value}
+                values={{ current: current?.anim.name }}
+                onChangeValues={xs => ctrl.setCurrent(xs.current)}
+                isDisabled={this.state.busy}
+            />
+            {current && <ParameterControls
+                params={current.params}
+                values={current.values}
+                onChangeValues={xs => ctrl.setCurrentParams(xs)}
+                isDisabled={this.state.busy}
+            />}
+            <div className='msp-control-offset msp-help-text'>
+                <div className='msp-help-description' style={{ textAlign: 'center' }}>
+                    Resolution: {info.width}x{info.height}<br />
+                    Adjust in viewport using <Icon svg={CameraOutlinedSvg} inline />
+                </div>
+            </div>
+            <ParameterControls
+                params={Mp4AnimationParams}
+                values={ctrl.behaviors.params.value}
+                onChangeValues={xs => ctrl.behaviors.params.next(xs)}
+                isDisabled={this.state.busy}
+            />
+            <Button onClick={this.generate} style={{ marginTop: 1 }}
+                disabled={this.state.busy || !canApply.canApply}
+                commit={canApply.canApply ? 'on' : 'off'}>
+                {canApply.canApply ? 'Render' : canApply.reason ?? 'Invalid params/state'}
+            </Button>
+        </>;
+    }
+
+    componentDidMount() {
+        const merged = merge(
+            this.controls.behaviors.animations,
+            this.controls.behaviors.current,
+            this.controls.behaviors.canApply,
+            this.controls.behaviors.info,
+            this.controls.behaviors.params
+        );
+
+        this.subscribe(merged.pipe(debounceTime(10)), () => {
+            if (!this.state.isCollapsed) this.forceUpdate();
+        });
+    }
+
+    componentWillUnmount() {
+        this._controls?.dispose();
+        this._controls = void 0;
+    }
+
+    save = () => {
+        download(new Blob([this.state.data!.movie]), this.state.data!.filename);
+    }
+
+    generate = async () => {
+        try {
+            this.setState({ busy: true });
+            const data = await this.controls.render();
+            console.log(data);
+            this.setState({ busy: false, data });
+        } catch {
+            this.setState({ busy: false });
+        }
+    }
+}

+ 4 - 0
src/mol-canvas3d/camera.ts

@@ -67,6 +67,10 @@ class Camera implements ICamera {
 
     update() {
         const snapshot = this.state as Camera.Snapshot;
+        if (snapshot.radiusMax === 0) {
+            return false;
+        }
+
         const height = 2 * Math.tan(snapshot.fov / 2) * Vec3.distance(snapshot.position, snapshot.target);
         this.zoom = this.viewport.height / height;
 

+ 39 - 5
src/mol-canvas3d/canvas3d.ts

@@ -96,19 +96,29 @@ interface Canvas3D {
      * This function must be called if animate() is not set up so that add/remove actions take place.
      */
     commit(isSynchronous?: boolean): void
+    /**
+     * Funcion for external "animation" control
+     * Calls commit.
+     */
+    tick(t: now.Timestamp, options?: { isSynchronous?: boolean, manualDraw?: boolean }): void
     update(repr?: Representation.Any, keepBoundingSphere?: boolean): void
     clear(): void
     syncVisibility(): void
 
     requestDraw(force?: boolean): void
+
+    /** Reset the timers, used by "animate" */
+    resetTime(t: number): void
     animate(): void
     pause(): void
     identify(x: number, y: number): PickData | undefined
     mark(loci: Representation.Loci, action: MarkerAction): void
     getLoci(pickingId: PickingId | undefined): Representation.Loci
 
+    notifyDidDraw: boolean,
     readonly didDraw: BehaviorSubject<now.Timestamp>
     readonly reprCount: BehaviorSubject<number>
+    readonly resized: BehaviorSubject<any>
 
     handleResize(): void
     /** Focuses camera on scene's bounding sphere, centered and zoomed. */
@@ -197,7 +207,7 @@ namespace Canvas3D {
         const reprUpdatedSubscriptions = new Map<Representation.Any, Subscription>();
         const reprCount = new BehaviorSubject(0);
 
-        const startTime = now();
+        let startTime = now();
         const didDraw = new BehaviorSubject<now.Timestamp>(0 as now.Timestamp);
 
         const { gl, contextRestored } = webgl;
@@ -231,6 +241,8 @@ namespace Canvas3D {
         let nextCameraResetDuration: number | undefined = void 0;
         let nextCameraResetSnapshot: Partial<Camera.Snapshot> | undefined = void 0;
 
+        let notifyDidDraw = true;
+
         function getLoci(pickingId: PickingId | undefined) {
             let loci: Loci = EmptyLoci;
             let repr: Representation.Any = Representation.Empty;
@@ -305,7 +317,7 @@ namespace Canvas3D {
         let currentTime = 0;
 
         function draw(force?: boolean) {
-            if (render(!!force || forceNextDraw)) {
+            if (render(!!force || forceNextDraw) && notifyDidDraw) {
                 didDraw.next(now() - startTime as now.Timestamp);
             }
             forceNextDraw = false;
@@ -320,19 +332,33 @@ namespace Canvas3D {
 
         let animationFrameHandle = 0;
 
-        function _animate() {
-            currentTime = now();
-            commit();
+        function tick(t: now.Timestamp, options?: { isSynchronous?: boolean, manualDraw?: boolean }) {
+            currentTime = t;
+            commit(options?.isSynchronous);
             camera.transition.tick(currentTime);
 
+            if (options?.manualDraw) {
+                return;
+            }
+
             draw(false);
             if (!camera.transition.inTransition && !webgl.isContextLost) {
                 interactionHelper.tick(currentTime);
             }
+        }
+
+        function _animate() {
+            tick(now());
             animationFrameHandle = requestAnimationFrame(_animate);
         }
 
+        function resetTime(t: now.Timestamp) {
+            startTime = t;
+            controls.start(t);
+        }
+
         function animate() {
+            controls.start(now());
             if (animationFrameHandle === 0) _animate();
         }
 
@@ -516,6 +542,8 @@ namespace Canvas3D {
             draw(true);
         });
 
+        const resized = new BehaviorSubject<any>(0);
+
         return {
             webgl,
 
@@ -553,7 +581,9 @@ namespace Canvas3D {
             },
 
             requestDraw,
+            tick,
             animate,
+            resetTime,
             pause,
             identify,
             mark,
@@ -564,6 +594,7 @@ namespace Canvas3D {
                 updateViewport();
                 syncViewport();
                 requestDraw(true);
+                resized.next(+new Date());
             },
             requestCameraReset: options => {
                 nextCameraResetDuration = options?.durationMs;
@@ -572,8 +603,11 @@ namespace Canvas3D {
             },
             camera,
             boundingSphere: scene.boundingSphere,
+            get notifyDidDraw() { return notifyDidDraw; },
+            set notifyDidDraw(v: boolean) { notifyDidDraw = v; },
             didDraw,
             reprCount,
+            resized,
             setProps: (properties, doNotRequestDraw = false) => {
                 const props: PartialCanvas3DProps = typeof properties === 'function'
                     ? produce(getProps(), properties)

+ 9 - 3
src/mol-canvas3d/controls/trackball.ts

@@ -60,6 +60,7 @@ interface TrackballControls {
     readonly props: Readonly<TrackballControlsProps>
     setProps: (props: Partial<TrackballControlsProps>) => void
 
+    start: (t: number) => void
     update: (t: number) => void
     reset: () => void
     dispose: () => void
@@ -286,7 +287,7 @@ namespace TrackballControls {
         /** Update the object's position, direction and up vectors */
         function update(t: number) {
             if (lastUpdated === t) return;
-            if (p.spin) spin(t - lastUpdated);
+            if (p.spin && lastUpdated > 0) spin(t - lastUpdated);
 
             Vec3.sub(_eye, camera.position, camera.target);
 
@@ -405,13 +406,17 @@ namespace TrackballControls {
 
         const _spinSpeed = Vec2.create(0.005, 0);
         function spin(deltaT: number) {
+            if (p.spinSpeed === 0) return;
+
             const frameSpeed = (p.spinSpeed || 0) / 1000;
             _spinSpeed[0] = 60 * Math.min(Math.abs(deltaT), 1000 / 8) / 1000 * frameSpeed;
             if (!_isInteracting) Vec2.add(_rotCurr, _rotPrev, _spinSpeed);
         }
 
-        // force an update at start
-        update(0);
+        function start(t: number) {
+            lastUpdated = -1;
+            update(t);
+        }
 
         return {
             viewport,
@@ -421,6 +426,7 @@ namespace TrackballControls {
                 Object.assign(p, props);
             },
 
+            start,
             update,
             reset,
             dispose

+ 15 - 8
src/mol-canvas3d/passes/image.ts

@@ -28,8 +28,8 @@ export const ImageParams = {
 export type ImageProps = PD.Values<typeof ImageParams>
 
 export class ImagePass {
-    private _width = 1024
-    private _height = 768
+    private _width = 0
+    private _height = 0
     private _camera = new Camera()
 
     readonly props: ImageProps
@@ -60,7 +60,7 @@ export class ImagePass {
             handle: helper.handle,
         };
 
-        this.setSize(this._width, this._height);
+        this.setSize(1024, 768);
     }
 
     setSize(width: number, height: number) {
@@ -100,13 +100,20 @@ export class ImagePass {
         }
     }
 
-    getImageData(width: number, height: number) {
+    getImageData(width: number, height: number, viewport?: Viewport) {
         this.setSize(width, height);
         this.render();
         this.colorTarget.bind();
-        const array = new Uint8Array(width * height * 4);
-        this.webgl.readPixels(0, 0, width, height, array);
-        PixelData.flipY({ array, width, height });
-        return new ImageData(new Uint8ClampedArray(array), width, height);
+
+        const w = viewport?.width ?? width, h = viewport?.height ?? height;
+
+        const array = new Uint8Array(w * h * 4);
+        if (!viewport) {
+            this.webgl.readPixels(0, 0, w, h, array);
+        } else {
+            this.webgl.readPixels(viewport.x, height - viewport.y - viewport.height, w, h, array);
+        }
+        PixelData.flipY({ array, width: w, height: h });
+        return new ImageData(new Uint8ClampedArray(array), w, h);
     }
 }

+ 2 - 0
src/mol-io/writer/mol/encoder.ts

@@ -44,6 +44,8 @@ export class MolEncoder extends LigandEncoder {
             StringBuilder.writeSafe(ctab, '  0  0  0  0  0  0  0  0  0  0\n');
             if (stereo_config !== 'N') chiral = true;
 
+            // no data for metal ions
+            if (!bondMap?.map) return;
             bondMap.map.get(label_atom_id1)!.forEach((bond, label_atom_id2) => {
                 const atom2 = atoms.get(label_atom_id2);
                 if (!atom2) return;

+ 15 - 13
src/mol-io/writer/mol2/encoder.ts

@@ -37,20 +37,22 @@ export class Mol2Encoder extends LigandEncoder {
         StringBuilder.writeSafe(b, '@<TRIPOS>BOND\n');
         atoms.forEach((atom1, label_atom_id1) => {
             const { index: i1 } = atom1;
-            bondMap.map.get(label_atom_id1)!.forEach((bond, label_atom_id2) => {
-                const atom2 = atoms.get(label_atom_id2);
-                if (!atom2) return;
-
-                const { index: i2, type_symbol: type_symbol2 } = atom2;
-                if (i1 < i2 && !this.skipHydrogen(type_symbol2)) {
-                    const { order, flags } = bond;
-                    const ar = BondType.is(BondType.Flag.Aromatic, flags);
-                    StringBuilder.writeSafe(b, `${++bondCount} ${i1 + 1} ${i2 + 1} ${ar ? 'ar' : order}`);
-                    StringBuilder.newline(b);
-                }
-            });
+            if (bondMap?.map) {
+                bondMap.map.get(label_atom_id1)!.forEach((bond, label_atom_id2) => {
+                    const atom2 = atoms.get(label_atom_id2);
+                    if (!atom2) return;
+
+                    const { index: i2, type_symbol: type_symbol2 } = atom2;
+                    if (i1 < i2 && !this.skipHydrogen(type_symbol2)) {
+                        const { order, flags } = bond;
+                        const ar = BondType.is(BondType.Flag.Aromatic, flags);
+                        StringBuilder.writeSafe(b, `${++bondCount} ${i1 + 1} ${i2 + 1} ${ar ? 'ar' : order}`);
+                        StringBuilder.newline(b);
+                    }
+                });
+            }
 
-            const sybyl = this.mapToSybyl(label_atom_id1, atom1.type_symbol, bondMap);
+            const sybyl = bondMap?.map ? this.mapToSybyl(label_atom_id1, atom1.type_symbol, bondMap) : atom1.type_symbol;
             StringBuilder.writeSafe(a, `${i1 + 1} ${label_atom_id1} ${atom1.Cartn_x.toFixed(3)} ${atom1.Cartn_y.toFixed(3)} ${atom1.Cartn_z.toFixed(3)} ${sybyl} 1 ${name} 0.000\n`);
         });
 

+ 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: { } };
-    }
-});

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

@@ -0,0 +1,99 @@
+/**
+ * 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' },
+    isExportable: true,
+    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 } };
+    }
+});

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

@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2020 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();
+
+type State = { snapshot: Camera.Snapshot };
+
+export const AnimateCameraSpin = PluginStateAnimation.create({
+    name: 'built-in.animate-camera-spin',
+    display: { name: 'Camera Spin' },
+    isExportable: true,
+    params: () => ({
+        durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
+        speed: PD.Numeric(1, { min: 1, max: 10, step: 1 }, { description: 'How many times to spin in the specified dutation.' }),
+        direction: PD.Select<'cw' | 'ccw'>('cw', [['cw', 'Clockwise'], ['ccw', 'Counter Clockwise']], { cycle: true })
+    }),
+    initialState: (_, ctx) => ({ snapshot: ctx.canvas3d?.camera.getSnapshot()! }) as State,
+    getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
+    teardown: (_, state: State, ctx) => {
+        ctx.canvas3d?.requestCameraReset({ snapshot: state.snapshot, durationMs: 0 });
+    },
+    async apply(animState: State, t, ctx) {
+        if (t.current === 0) {
+            return { kind: 'next', state: animState };
+        }
+
+        const snapshot = animState.snapshot;
+        if (snapshot.radiusMax < 0.0001) {
+            return { kind: 'finished' };
+        }
+
+        const phase = clamp(t.current / ctx.params.durationInMs, 0, 1);
+
+        if (phase >= 0.99999) {
+            ctx.plugin.canvas3d?.requestCameraReset({ snapshot, durationMs: 0 });
+            return { kind: 'finished' };
+        }
+
+        const angle = 2 * Math.PI * phase * ctx.params.speed * (ctx.params.direction === 'ccw' ? -1 : 1);
+
+        Vec3.sub(_dir, snapshot.position, snapshot.target);
+        Vec3.normalize(_axis, snapshot.up);
+        Quat.setAxisAngle(_rot, _axis, angle);
+        Vec3.transformQuat(_dir, _dir, _rot);
+        const position = Vec3.add(Vec3(), snapshot.target, _dir);
+        ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position }, durationMs: 0 });
+
+        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: { } };
+    }
+});

+ 21 - 2
src/mol-plugin-state/animation/model.ts

@@ -15,14 +15,16 @@ export { PluginStateAnimation };
 interface PluginStateAnimation<P = any, S = any> {
     name: string,
     readonly display: { readonly name: string, readonly description?: string },
+    readonly isExportable?: boolean,
 
     params(ctx: PluginContext): PD.For<P>,
-    canApply?(ctx: PluginContext): { canApply: true } | { canApply: false, reason?: string },
+    canApply?(ctx: PluginContext): PluginStateAnimation.CanApply,
     initialState(params: P, ctx: PluginContext): S,
+    getDuration?(params: P, ctx: PluginContext): PluginStateAnimation.Duration,
 
     // TODO: support state in setup/teardown?
     setup?(params: P, ctx: PluginContext): void | Promise<void>,
-    teardown?(params: P, ctx: PluginContext): void | Promise<void>,
+    teardown?(params: P, state: S, ctx: PluginContext): void | Promise<void>,
 
     /**
      * Apply the current frame and modify the state.
@@ -38,6 +40,15 @@ interface PluginStateAnimation<P = any, S = any> {
 }
 
 namespace PluginStateAnimation {
+    export type CanApply = { canApply: true } | { canApply: false, reason?: string }
+    export type Duration = { kind: 'unknown' } | { kind: 'infinite' } | { kind: 'fixed', durationMs: number  }
+
+    export interface Instance<A extends PluginStateAnimation> {
+        definition: PluginStateAnimation,
+        params: Params<A>,
+        customDurationMs?: number
+    }
+
     export interface Time {
         lastApplied: number,
         current: number
@@ -49,7 +60,15 @@ namespace PluginStateAnimation {
         plugin: PluginContext
     }
 
+    export type Params<A extends PluginStateAnimation> = A extends PluginStateAnimation<infer P> ? P : never
+
     export function create<P, S>(params: PluginStateAnimation<P, S>) {
         return params;
     }
+
+    export function getDuration<A extends PluginStateAnimation>(ctx: PluginContext, instance: Instance<A>) {
+        if (instance.customDurationMs) return instance.customDurationMs;
+        const d = instance.definition.getDuration?.(instance.params, ctx);
+        if (d?.kind === 'fixed') return d.durationMs;
+    }
 }

+ 44 - 22
src/mol-plugin-state/manager/animation.ts

@@ -17,7 +17,9 @@ export { PluginAnimationManager };
 
 class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationManager.State> {
     private map = new Map<string, PluginStateAnimation>();
-    private animations: PluginStateAnimation[] = [];
+    private _animations: PluginStateAnimation[] = [];
+    private currentTime: number = 0;
+
     private _current: PluginAnimationManager.Current;
     private _params?: PD.For<PluginAnimationManager.State['params']> = void 0;
 
@@ -26,9 +28,11 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
         applied: this.ev(),
     };
 
-    get isEmpty() { return this.animations.length === 0; }
+    get isEmpty() { return this._animations.length === 0; }
     get current() { return this._current!; }
 
+    get animations() { return this._animations; }
+
     private triggerUpdate() {
         this.events.updated.next();
     }
@@ -40,8 +44,8 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
     getParams(): PD.Params {
         if (!this._params) {
             this._params = {
-                current: PD.Select(this.animations[0] && this.animations[0].name,
-                    this.animations.map(a => [a.name, a.display.name] as [string, string]),
+                current: PD.Select(this._animations[0] && this._animations[0].name,
+                    this._animations.map(a => [a.name, a.display.name] as [string, string]),
                     { label: 'Animation' })
             };
         }
@@ -77,24 +81,38 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
         }
         this._params = void 0;
         this.map.set(animation.name, animation);
-        this.animations.push(animation);
-        if (this.animations.length === 1) {
+        this._animations.push(animation);
+        if (this._animations.length === 1) {
             this.updateParams({ current: animation.name });
         } else {
             this.triggerUpdate();
         }
     }
 
-    play<P>(animation: PluginStateAnimation<P>, params: P) {
-        this.stop();
+    async play<P>(animation: PluginStateAnimation<P>, params: P) {
+        await this.stop();
         if (!this.map.has(animation.name)) {
             this.register(animation);
         }
         this.updateParams({ current: animation.name });
         this.updateCurrentParams(params);
-        this.start();
+        await this.start();
+    }
+
+    async tick(t: number, isSynchronous?: boolean) {
+        this.currentTime = t;
+        if (this.isStopped) return;
+
+        if (isSynchronous) {
+            await this.applyFrame();
+        } else {
+            this.applyAsync();
+        }
     }
 
+    private isStopped = true;
+    private isApplying = false;
+
     async start() {
         this.updateState({ animationState: 'playing' });
         if (!this.context.behaviors.state.isAnimating.value) {
@@ -109,18 +127,16 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
 
         this._current.lastTime = 0;
         this._current.startedTime = -1;
-        this._current.state = this._current.anim.initialState(anim, this.context);
-
-        requestAnimationFrame(this.animate);
+        this._current.state = this._current.anim.initialState(this._current.paramValues, this.context);
+        this.isStopped = false;
     }
 
     async stop() {
-        if (typeof this._frame !== 'undefined') cancelAnimationFrame(this._frame);
-
+        this.isStopped = true;
         if (this.state.animationState !== 'stopped') {
             const anim = this._current.anim;
             if (anim.teardown) {
-                await anim.teardown(this._current.paramValues, this.context);
+                await anim.teardown(this._current.paramValues, this._current.state, this.context);
             }
 
             this.updateState({ animationState: 'stopped' });
@@ -136,10 +152,19 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
         return this.state.animationState === 'playing';
     }
 
-    private _frame: number | undefined = void 0;
-    private animate = async (t: number) => {
-        this._frame = void 0;
+    private async applyAsync() {
+        if (this.isApplying) return;
+
+        this.isApplying = true;
+        try {
+            await this.applyFrame();
+        } finally {
+            this.isApplying = false;
+        }
+    }
 
+    private async applyFrame() {
+        const t = this.currentTime;
         if (this._current.startedTime < 0) this._current.startedTime = t;
         const newState = await this._current.anim.apply(
             this._current.state,
@@ -151,9 +176,6 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
         } else if (newState.kind === 'next') {
             this._current.state = newState.state;
             this._current.lastTime = t - this._current.startedTime;
-            if (this.state.animationState === 'playing') this._frame = requestAnimationFrame(this.animate);
-        } else if (newState.kind === 'skip') {
-            if (this.state.animationState === 'playing') this._frame = requestAnimationFrame(this.animate);
         }
         this.triggerApply();
     }
@@ -195,7 +217,7 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
         if (anim.setup) {
             await anim.setup(this._current.paramValues, this.context);
         }
-        requestAnimationFrame(this.animate);
+        this.isStopped = false;
     }
 
     constructor(private context: PluginContext) {

+ 2 - 1
src/mol-plugin-ui/controls/common.tsx

@@ -308,6 +308,7 @@ export function IconButton(props: {
 
 export type ToggleButtonProps = {
     style?: React.CSSProperties,
+    inline?: boolean,
     className?: string,
     disabled?: boolean,
     label?: string | JSX.Element,
@@ -327,7 +328,7 @@ export class ToggleButton extends React.PureComponent<ToggleButtonProps> {
         const props = this.props;
         const label = props.label;
         const className = props.isSelected ? `${props.className || ''} msp-control-current` : props.className;
-        return <Button icon={this.props.icon} onClick={this.onClick} title={this.props.title}
+        return <Button icon={this.props.icon} onClick={this.onClick} title={this.props.title} inline={this.props.inline}
             disabled={props.disabled} style={props.style} className={className}>
             {label && this.props.isSelected ? <b>{label}</b> : label}
         </Button>;

+ 8 - 0
src/mol-plugin-ui/controls/icons.tsx

@@ -97,6 +97,14 @@ const _CloudUpload = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d
 export function CloudUploadSvg() { return _CloudUpload; }
 const _Code = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z' /></svg>;
 export function CodeSvg() { return _Code; }
+const _Copy = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm-1 4H8c-1.1 0-1.99.9-1.99 2L6 21c0 1.1.89 2 1.99 2H19c1.1 0 2-.9 2-2V11l-6-6zM8 21V7h6v5h5v9H8z' /></svg>;
+export function CopySvg() { return _Copy; }
+const _Crop = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M17 15h2V7c0-1.1-.9-2-2-2H9v2h8v8zM7 17V1H5v4H1v2h4v10c0 1.1.9 2 2 2h10v4h2v-4h4v-2H7z' /></svg>;
+export function CropSvg() { return _Crop; }
+const _CropFree = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2z' /></svg>;
+export function CropFreeSvg() { return _CropFree; }
+const _CropOriginal = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5.04-6.71l-2.75 3.54-1.96-2.36L6.5 17h11l-3.54-4.71z' /></svg>;
+export function CropOrginalSvg() { return _CropOriginal; }
 const _DeleteOutlined = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z' /></svg>;
 export function DeleteOutlinedSvg() { return _DeleteOutlined; }
 const _Delete = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z' /></svg>;

+ 324 - 0
src/mol-plugin-ui/controls/screenshot.tsx

@@ -0,0 +1,324 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { Observable, Subscription } from 'rxjs';
+import { Viewport } from '../../mol-canvas3d/camera/util';
+import { PluginContext } from '../../mol-plugin/context';
+import { ViewportScreenshotHelper } from '../../mol-plugin/util/viewport-screenshot';
+import { shallowEqual } from '../../mol-util/object';
+import { useBehavior } from '../hooks/use-behavior';
+
+export interface ScreenshotPreviewProps {
+    plugin: PluginContext,
+    suspend?: boolean,
+    cropFrameColor?: string,
+    borderColor?: string,
+    borderWidth?: number,
+    customBackground?: string
+}
+
+const _ScreenshotPreview = (props: ScreenshotPreviewProps) => {
+    const { plugin, cropFrameColor } = props;
+
+    const helper = plugin.helpers.viewportScreenshot!;
+    const [currentCanvas, setCurrentCanvas] = useState<HTMLCanvasElement | null>(null);
+    const canvasRef = useRef<HTMLCanvasElement | null>(null);
+    const propsRef = useRef(props);
+
+    useEffect(() => {
+        propsRef.current = props;
+    }, Object.values(props));
+
+    useEffect(() => {
+        if (currentCanvas !== canvasRef.current) {
+            setCurrentCanvas(canvasRef.current);
+        }
+    });
+
+    useEffect(() => {
+        let isDirty = false;
+        const subs: Subscription[] = [];
+
+        function subscribe<T>(xs: Observable<T> | undefined, f: (v: T) => any) {
+            if (!xs) return;
+            subs.push(xs.subscribe(f));
+        }
+
+        function preview() {
+            const p = propsRef.current;
+            if (!p.suspend && canvasRef.current) {
+                drawPreview(helper, canvasRef.current, p.customBackground, p.borderColor, p.borderWidth);
+            }
+
+            if (!canvasRef.current) isDirty = true;
+        }
+
+        const interval = setInterval(() => {
+            if (isDirty) {
+                isDirty = false;
+                preview();
+            }
+        }, 1000 / 8);
+
+        subscribe(plugin.events.canvas3d.settingsUpdated, () => isDirty = true);
+        subscribe(plugin.canvas3d?.didDraw, () => isDirty = true);
+        subscribe(plugin.state.data.behaviors.isUpdating, v => {
+            if (!v) isDirty = true;
+        });
+        subscribe(helper.behaviors.values, () => isDirty = true);
+        subscribe(helper.behaviors.cropParams, () => isDirty = true);
+
+        let resizeObserver: any = void 0;
+        if (typeof ResizeObserver !== 'undefined') {
+            resizeObserver = new ResizeObserver(() => isDirty = true);
+        }
+
+        const canvas = canvasRef.current;
+        resizeObserver?.observe(canvas);
+
+        preview();
+
+        return () => {
+            clearInterval(interval);
+            subs.forEach(s => s.unsubscribe());
+            resizeObserver?.unobserve(canvas);
+        };
+    }, [helper]);
+
+    useLayoutEffect(() => {
+        if (canvasRef.current) {
+            drawPreview(helper, canvasRef.current, props.customBackground, props.borderColor, props.borderWidth);
+        }
+    }, [...Object.values(props)]);
+
+    return <>
+        <div style={{ position: 'relative', width: '100%', height: '100%' }}>
+            <canvas ref={canvasRef} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }} style={{ display: 'block', width: '100%', height: '100%' }}></canvas>
+            <ViewportFrame plugin={plugin} canvas={currentCanvas} color={cropFrameColor} />
+        </div>
+    </>;
+};
+
+export const ScreenshotPreview = React.memo(_ScreenshotPreview, (prev, next) => shallowEqual(prev, next));
+
+declare const ResizeObserver: any;
+
+function drawPreview(helper: ViewportScreenshotHelper, target: HTMLCanvasElement, customBackground?: string, borderColor?: string, borderWidth?: number) {
+    const { canvas, width, height } = helper.getPreview()!;
+    const ctx = target.getContext('2d');
+    if (!ctx) return;
+
+    const w = target.clientWidth;
+    const h = target.clientHeight;
+    target.width = w;
+    target.height = h;
+
+    ctx.clearRect(0, 0, w, h);
+    const frame = getViewportFrame(width, height, w, h);
+
+    if (customBackground) {
+        ctx.fillStyle = customBackground;
+        ctx.fillRect(frame.x, frame.y, frame.width, frame.height);
+    } else if (helper.values.transparent) {
+        // must be an odd number
+        const s = 13;
+        for (let i = 0; i < frame.width; i += s) {
+            for (let j = 0; j < frame.height; j += s) {
+                ctx.fillStyle = (i + j) % 2 ? '#ffffff' : '#bfbfbf';
+
+                const x = frame.x + i, y = frame.y + j;
+                const w = i + s > frame.width ? frame.width - i : s;
+                const h = j + s > frame.height ? frame.height - j : s;
+                ctx.fillRect(x, y, w, h);
+            }
+        }
+    }
+    ctx.drawImage(canvas, frame.x, frame.y, frame.width, frame.height);
+
+    if (borderColor && borderWidth) {
+        const w = borderWidth;
+        ctx.rect(frame.x, frame.y, frame.width, frame.height);
+        ctx.rect(frame.x + w, frame.y + w, frame.width - 2 * w, frame.height - 2 * w);
+        ctx.fillStyle = borderColor;
+        ctx.fill('evenodd');
+    }
+}
+
+function ViewportFrame({ plugin, canvas, color = 'rgba(255, 87, 45, 0.75)' }: { plugin: PluginContext, canvas: HTMLCanvasElement | null, color?: string }) {
+    const helper = plugin.helpers.viewportScreenshot;
+    const params = useBehavior(helper?.behaviors.values!);
+    const cropParams = useBehavior(helper?.behaviors.cropParams!);
+    const crop = useBehavior(helper?.behaviors.relativeCrop!);
+    const cropFrameRef = useRef<Viewport>({ x: 0, y: 0, width: 0, height: 0 });
+    useBehavior(params?.resolution.name === 'viewport' ? plugin.canvas3d?.resized : void 0);
+
+    const [drag, setDrag] = React.useState<string>('');
+    const [start, setStart] = useState([0, 0]);
+    const [current, setCurrent] = useState([0, 0]);
+
+    if (!helper || !canvas) return null;
+
+    const { width, height } = helper.getSizeAndViewport();
+
+    const frame = getViewportFrame(width, height, canvas.clientWidth, canvas.clientHeight);
+
+    const cropFrame: Viewport = {
+        x: frame.x + Math.floor(frame.width * crop.x),
+        y: frame.y + Math.floor(frame.height * crop.y),
+        width: Math.ceil(frame.width * crop.width),
+        height: Math.ceil(frame.height * crop.height)
+    };
+
+    const rectCrop = toRect(cropFrame);
+    const rectFrame = toRect(frame);
+
+    if (drag === 'move') {
+        rectCrop.l += current[0] - start[0];
+        rectCrop.r += current[0] - start[0];
+        rectCrop.t += current[1] - start[1];
+        rectCrop.b += current[1] - start[1];
+    } else if (drag) {
+        if (drag.indexOf('left') >= 0) {
+            rectCrop.l += current[0] - start[0];
+        } else if (drag.indexOf('right') >= 0) {
+            rectCrop.r += current[0] - start[0];
+        }
+
+        if (drag.indexOf('top') >= 0) {
+            rectCrop.t += current[1] - start[1];
+        } else if (drag.indexOf('bottom') >= 0) {
+            rectCrop.b += current[1] - start[1];
+        }
+    }
+
+    if (rectCrop.l > rectCrop.r) {
+        const t = rectCrop.l;
+        rectCrop.l = rectCrop.r;
+        rectCrop.r = t;
+    }
+
+    if (rectCrop.t > rectCrop.b) {
+        const t = rectCrop.t;
+        rectCrop.t = rectCrop.b;
+        rectCrop.b = t;
+    }
+
+    const pad = 40;
+    rectCrop.l = Math.min(rectFrame.r - pad, Math.max(rectFrame.l, rectCrop.l));
+    rectCrop.r = Math.max(rectFrame.l + pad, Math.min(rectFrame.r, rectCrop.r));
+    rectCrop.t = Math.min(rectFrame.b - pad, Math.max(rectFrame.t, rectCrop.t));
+    rectCrop.b = Math.max(rectFrame.t + pad, Math.min(rectFrame.b, rectCrop.b));
+
+    cropFrame.x = rectCrop.l;
+    cropFrame.y = rectCrop.t;
+    cropFrame.width = rectCrop.r - rectCrop.l + 1;
+    cropFrame.height = rectCrop.b - rectCrop.t + 1;
+
+    cropFrameRef.current = cropFrame;
+
+    const onMove = (e: MouseEvent) => {
+        e.preventDefault();
+        setCurrent([e.pageX, e.pageY]);
+    };
+
+    const onTouchMove = (e: TouchEvent) => {
+        e.preventDefault();
+        const t = e.touches[0];
+        setCurrent([t.pageX, t.pageY]);
+    };
+
+    const onTouchStart = (e: React.TouchEvent) => {
+        e.preventDefault();
+        setDrag(e.currentTarget.getAttribute('data-drag')! as any);
+        const t = e.touches[0];
+        const p = [t.pageX, t.pageY];
+        setStart(p);
+        setCurrent(p);
+        window.addEventListener('touchend', onTouchEnd);
+        window.addEventListener('touchmove', onTouchMove);
+    };
+
+    const onStart = (e: React.MouseEvent<HTMLElement>) => {
+        e.preventDefault();
+        setDrag(e.currentTarget.getAttribute('data-drag')! as any);
+        const p = [e.pageX, e.pageY];
+        setStart(p);
+        setCurrent(p);
+        window.addEventListener('mouseup', onEnd);
+        window.addEventListener('mousemove', onMove);
+    };
+
+    const onEnd = () => {
+        window.removeEventListener('mouseup', onEnd);
+        window.removeEventListener('mousemove', onMove);
+        finish();
+    };
+
+    const onTouchEnd = () => {
+        window.removeEventListener('touchend', onTouchEnd);
+        window.removeEventListener('touchmove', onTouchMove);
+        finish();
+    };
+
+    function finish() {
+        const cropFrame = cropFrameRef.current;
+        if (cropParams.auto) {
+            helper?.behaviors.cropParams.next({ ...cropParams, auto: false });
+        }
+        helper?.behaviors.relativeCrop.next({
+            x: (cropFrame.x - frame.x) / frame.width,
+            y: (cropFrame.y - frame.y) / frame.height,
+            width: cropFrame.width / frame.width,
+            height: cropFrame.height / frame.height
+        });
+        setDrag('');
+        const p = [0, 0];
+        setStart(p);
+        setCurrent(p);
+    }
+
+    const contextMenu = (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+    };
+
+    const d = 4;
+    const border = `3px solid ${color}`;
+    const transparent = 'transparent';
+
+    return <>
+        <div data-drag='move' style={{ position: 'absolute', left: cropFrame.x, top: cropFrame.y, width: cropFrame.width, height: cropFrame.height, border, cursor: 'move' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+
+        <div data-drag='left' style={{ position: 'absolute', left: cropFrame.x - d, top: cropFrame.y + d, width: 4 * d, height: cropFrame.height - d, background: transparent, cursor: 'w-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+        <div data-drag='right' style={{ position: 'absolute', left: rectCrop.r - 2 * d, top: cropFrame.y, width: 4 * d, height: cropFrame.height - d, background: transparent, cursor: 'w-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+        <div data-drag='top' style={{ position: 'absolute', left: cropFrame.x - d, top: cropFrame.y - d, width: cropFrame.width + 2 * d, height: 4 * d, background: transparent, cursor: 'n-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+        <div data-drag='bottom' style={{ position: 'absolute', left: cropFrame.x - d, top: rectCrop.b - 2 * d, width: cropFrame.width + 2 * d, height: 4 * d, background: transparent, cursor: 'n-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+
+        <div data-drag='top, left' style={{ position: 'absolute', left: rectCrop.l - d, top: rectCrop.t - d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'nw-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+        <div data-drag='bottom, right' style={{ position: 'absolute', left: rectCrop.r - 2 * d, top: rectCrop.b - 2 * d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'nw-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+        <div data-drag='top, right' style={{ position: 'absolute', left: rectCrop.r - 2 * d, top: rectCrop.t - d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'ne-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+        <div data-drag='bottom, left' style={{ position: 'absolute', left: rectCrop.l - d, top: rectCrop.b - 2 * d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'ne-resize' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
+    </>;
+}
+
+function toRect(viewport: Viewport) {
+    return { l: viewport.x, t: viewport.y, r: viewport.x + viewport.width - 1, b: viewport.y + viewport.height - 1 };
+}
+
+function getViewportFrame(srcWidth: number, srcHeight: number, w: number, h: number): Viewport {
+    const a0 = srcWidth / srcHeight;
+    const a1 = w / h;
+
+    if (a0 <= a1) {
+        const t = h * a0;
+        return { x: Math.round((w - t) / 2), y: 0, width: Math.round(t), height: h };
+    } else {
+        const t = w / a0;
+        return { x: 0, y: Math.round((h - t) / 2), width: w, height: Math.round(t) };
+    }
+}

+ 38 - 0
src/mol-plugin-ui/hooks/use-behavior.ts

@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { useEffect, useState } from 'react';
+
+interface Behavior<T> {
+    value: T;
+    subscribe(f: (v: T) => void): { unsubscribe(): void };
+}
+
+export function useBehavior<T>(s: Behavior<T>): T;
+// eslint-disable-next-line
+export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined;
+// eslint-disable-next-line
+export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined {
+    const [value, setValue] = useState(s?.value);
+
+    useEffect(() => {
+        if (!s) return;
+        let fst = true;
+        const sub = s.subscribe((v) => {
+            if (fst) {
+                fst = false;
+                if (v !== value) setValue(v);
+            } else setValue(v);
+        });
+
+        return () => {
+            sub.unsubscribe();
+        };
+        // eslint-disable-next-line
+    }, [s]);
+
+    return value;
+}

+ 2 - 1
src/mol-plugin-ui/plugin.tsx

@@ -14,7 +14,7 @@ import { PluginReactContext, PluginUIComponent } from './base';
 import { AnimationViewportControls, DefaultStructureTools, LociLabels, StateSnapshotViewportControls, TrajectoryViewportControls, SelectionViewportControls } from './controls';
 import { LeftPanelControls } from './left-panel';
 import { SequenceView } from './sequence';
-import { BackgroundTaskProgress } from './task';
+import { BackgroundTaskProgress, OverlayTaskProgress } from './task';
 import { Toasts } from './toast';
 import { Viewport, ViewportControls } from './viewport';
 import { PluginCommands } from '../mol-plugin/commands';
@@ -139,6 +139,7 @@ class Layout extends PluginUIComponent {
                     {layout.showControls && controls.right !== 'none' && this.region('right', controls.right || ControlsWrapper)}
                     {layout.showControls && controls.bottom !== 'none' && this.region('bottom', controls.bottom || Log)}
                 </div>
+                {!this.plugin.spec.components?.hideTaskOverlay && <OverlayTaskProgress />}
             </div>
         </div>;
     }

+ 20 - 11
src/mol-plugin-ui/skin/base/components/controls.scss

@@ -388,19 +388,10 @@
     position: relative;
     background: $default-background;
     margin-top: 1px;
-    text-align: center;
     padding: $control-spacing;
 
-    img {
-        max-height: 180px;
-        max-width: 100%;
-        display: 'block';
-
-        background-color: $default-background;
-    background-image: linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey),
-    linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
-    background-size: 30px 30px;
-    background-position: 0 0, 15px 15px;
+    canvas {
+        @include user-select(none);
     }
 
     > span {
@@ -408,5 +399,23 @@
         display: block;
         text-align: center;
         font-size: 80%;
+        line-height: 15px;
+    }
+}
+
+.msp-copy-image-wrapper {
+    position: relative;
+
+    div {
+        font-weight: bold;
+        padding: 3px;
+        margin: 1px 0;
+        width: 100%;
+        background: $msp-form-control-background;
+        text-align: center;
+    }
+
+    img {
+        margin-top: 1px;
     }
 }

+ 28 - 55
src/mol-plugin-ui/skin/base/components/tasks.scss

@@ -12,72 +12,45 @@
     }
 }
 
-/* overlay */
-
-.msp-overlay {
+.msp-overlay-tasks {
     position: absolute;
+    display: flex;
     top: 0;
     left: 0;
     bottom: 0;
     right: 0;
+    height: 100%;
+    width: 100%;
     z-index: 1000;
+    justify-content: center;
+    align-items: center;
+    background: rgba(0, 0, 0, 0.25);
 
-    .msp-overlay-background {
-        position: absolute;
-        top: 0;
-        left: 0;
-        bottom: 0;
-        right: 0;
-        background: transparent;
-        //background: black;
-        //opacity: 0.5;
-    }
-
-    .msp-overlay-content-wrap {
-        position: absolute;
-        top: 0;
-        left: 0;
-        bottom: 0;
-        right: 0;
-        display: block;
-        width: 100%;
-        height: 100%;
-    }
-
-    .msp-overlay-content {
-        text-align: center;
+    .msp-task-state {
+        $size: $row-height;
 
         > div {
+            height: $size;
+            margin-top: 1px;
+            position: relative;
+            width: 100%;
+            background: $default-background;
 
-            padding-top: 2 * $row-height;
-
-            .msp-task-state {
-                $size: $row-height;
-                text-align: center;
-
-                > div {
-                    height: $size;
-                    margin-top: $control-spacing;
-                    position: relative;
-                    text-align: center;
-                    width: 100%;
-
-                    > div {
-                        height: $size;
-                        line-height: $size;
-                        display: inline-block;
-                        background: $default-background;
-                        padding: 0 ($control-spacing);
-                        font-weight: bold;
-                        @include non-selectable;
-                    }
+            > div {
+                height: $size;
+                line-height: $size;
+                display: inline-block;
+                padding: 0 ($control-spacing);
+                @include non-selectable;
+                white-space: nowrap;
+                background: $default-background;
+                position: absolute;
+            }
 
-                    > button {
-                        display: inline-block;
-                        margin-top: -3px;
-                        font-size: 140%;
-                    }
-                }
+            > button {
+                display: inline-block;
+                margin-top: -3px;
+                // font-size: 140%;
             }
         }
     }

+ 23 - 1
src/mol-plugin-ui/task.tsx

@@ -15,7 +15,8 @@ import { CancelSvg } from './controls/icons';
 
 export class BackgroundTaskProgress extends PluginUIComponent<{ }, { tracked: OrderedMap<number, TaskManager.ProgressEvent> }> {
     componentDidMount() {
-        this.subscribe(this.plugin.events.task.progress.pipe(filter(e => e.level !== 'none')), e => {
+        const hideOverlay = !!this.plugin.spec.components?.hideTaskOverlay;
+        this.subscribe(this.plugin.events.task.progress.pipe(filter(e => e.level === 'background' && (hideOverlay || !e.useOverlay))), e => {
             this.setState({ tracked: this.state.tracked.set(e.id, e) });
         });
         this.subscribe(this.plugin.events.task.finished, ({ id }) => {
@@ -63,4 +64,25 @@ function countSubtasks(progress: Progress.Node) {
     let sum = 0;
     for (const c of progress.children) sum += countSubtasks(c);
     return sum;
+}
+
+export class OverlayTaskProgress extends PluginUIComponent<{ }, { tracked: OrderedMap<number, TaskManager.ProgressEvent> }> {
+    componentDidMount() {
+        this.subscribe(this.plugin.events.task.progress.pipe(filter(e => !!e.useOverlay)), e => {
+            this.setState({ tracked: this.state.tracked.set(e.id, e) });
+        });
+        this.subscribe(this.plugin.events.task.finished, ({ id }) => {
+            this.setState({ tracked: this.state.tracked.delete(id) });
+        });
+    }
+
+    state = { tracked: OrderedMap<number, TaskManager.ProgressEvent>() };
+
+    render() {
+        if (this.state.tracked.size === 0) return null;
+
+        return <div className='msp-overlay-tasks'>
+            {this.state.tracked.valueSeq().map(e => <ProgressEntry key={e!.id} event={e!} />)}
+        </div>;
+    }
 }

+ 66 - 101
src/mol-plugin-ui/viewport/screenshot.tsx

@@ -6,126 +6,55 @@
  */
 
 import * as React from 'react';
-import { ParamDefinition as PD } from '../../mol-util/param-definition';
-import { ParameterControls } from '../controls/parameters';
-import { PluginUIComponent } from '../base';
-import { debounceTime } from 'rxjs/operators';
-import { Subject } from 'rxjs';
-import { ViewportScreenshotHelper } from '../../mol-plugin/util/viewport-screenshot';
-import { Button, ExpandGroup } from '../controls/common';
-import { CameraHelperProps } from '../../mol-canvas3d/helper/camera-helper';
 import { PluginCommands } from '../../mol-plugin/commands';
-import { StateExportImportControls, LocalStateSnapshotParams } from '../state/snapshots';
-import { GetAppSvg, LaunchSvg } from '../controls/icons';
+import { PluginContext } from '../../mol-plugin/context';
+import { PluginUIComponent } from '../base';
+import { Button, ExpandGroup, ToggleButton } from '../controls/common';
+import { CopySvg, CropFreeSvg, CropOrginalSvg, CropSvg, GetAppSvg } from '../controls/icons';
+import { ParameterControls } from '../controls/parameters';
+import { ScreenshotPreview } from '../controls/screenshot';
+import { useBehavior } from '../hooks/use-behavior';
+import { LocalStateSnapshotParams, StateExportImportControls } from '../state/snapshots';
 
 interface ImageControlsState {
-    showPreview: boolean
-    isDisabled: boolean
-
-    resolution?: ViewportScreenshotHelper.ResolutionSettings,
-    transparent?: boolean,
-    axes?: CameraHelperProps['axes']
+    showPreview: boolean,
+    isDisabled: boolean,
+    imageData?: string
 }
 
 export class DownloadScreenshotControls extends PluginUIComponent<{ close: () => void }, ImageControlsState> {
     state: ImageControlsState = {
         showPreview: true,
-        isDisabled: false,
-        resolution: this.plugin.helpers.viewportScreenshot?.currentResolution,
-        transparent: this.plugin.helpers.viewportScreenshot?.transparent,
-        axes: this.plugin.helpers.viewportScreenshot?.axes
+        isDisabled: false
     } as ImageControlsState
 
-    private imgRef = React.createRef<HTMLImageElement>()
-    private updateQueue = new Subject();
-
-    private preview = async () => {
-        if (!this.imgRef.current) return;
-        this.imgRef.current!.src = await this.plugin.helpers.viewportScreenshot!.imageData();
-    }
-
     private download = () => {
         this.plugin.helpers.viewportScreenshot?.download();
         this.props.close();
     }
 
-    private openTab = () => {
-        // modified from https://stackoverflow.com/questions/16245767/creating-a-blob-from-a-base64-string-in-javascript/16245768#16245768
-
-        const base64 = this.imgRef.current!.src;
-        const byteCharacters = atob(base64.substr(`data:image/png;base64,`.length));
-        const byteArrays = [];
-
-        const sliceSize = Math.min(byteCharacters.length, 1024 * 1024);
-        for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
-            const byteNumbers = new Uint8Array(Math.min(sliceSize, byteCharacters.length - offset));
-            for (let i = 0, _i = byteNumbers.length; i < _i; i++) {
-                byteNumbers[i] = byteCharacters.charCodeAt(offset + i);
-            }
-            byteArrays.push(byteNumbers);
-        }
-        const blob = new Blob(byteArrays, { type: 'image/png' });
-        const blobUrl = URL.createObjectURL(blob);
-
-        window.open(blobUrl, '_blank');
-        this.props.close();
-    }
-
-    private handlePreview() {
-        if (this.state.showPreview) {
-            this.preview();
-        }
+    private copy = async () => {
+        await this.plugin.helpers.viewportScreenshot?.copyToClipboard();
+        PluginCommands.Toast.Show(this.plugin, {
+            message: 'Copied to clipboard.',
+            title: 'Screenshot',
+            timeoutMs: 1500
+        });
     }
 
-    componentDidUpdate() {
-        this.updateQueue.next();
+    private copyImg = async () => {
+        const src = await this.plugin.helpers.viewportScreenshot?.getImageDataUri();
+        this.setState({ imageData: src });
     }
 
     componentDidMount() {
-        if (!this.plugin.canvas3d) return;
-
-        this.subscribe(debounceTime(250)(this.updateQueue), () => this.handlePreview());
-
-        this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => {
-            this.plugin.helpers.viewportScreenshot!.imagePass.setProps({
-                multiSample: { mode: 'on', sampleLevel: 2 },
-                postprocessing: this.plugin.canvas3d?.props.postprocessing
-            });
-            this.updateQueue.next();
-        });
-
-        this.subscribe(debounceTime(250)(this.plugin.canvas3d.didDraw), () => {
-            if (this.state.isDisabled) return;
-            this.updateQueue.next();
-        });
-
         this.subscribe(this.plugin.state.data.behaviors.isUpdating, v => {
             this.setState({ isDisabled: v });
-            if (!v) this.updateQueue.next();
         });
-
-        this.handlePreview();
-    }
-
-    private setProps = (p: { param: PD.Base<any>, name: string, value: any }) => {
-        if (p.name === 'resolution') {
-            this.plugin.helpers.viewportScreenshot!.currentResolution = p.value;
-            this.setState({ resolution: p.value });
-        } else if (p.name === 'transparent') {
-            this.plugin.helpers.viewportScreenshot!.transparent = p.value;
-            this.setState({ transparent: p.value });
-        } else if (p.name === 'axes') {
-            this.plugin.helpers.viewportScreenshot!.axes = p.value;
-            this.setState({ axes: p.value });
-        }
-    }
-
-    downloadToFileJson = () => {
-        PluginCommands.State.Snapshots.DownloadToFile(this.plugin, { type: 'json' });
     }
 
-    downloadToFileZip = () => {
-        PluginCommands.State.Snapshots.DownloadToFile(this.plugin, { type: 'zip' });
+    componentWillUnmount() {
+        this.setState({ imageData: void 0 });
     }
 
     open = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -134,16 +63,24 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
     }
 
     render() {
+        const hasClipboardApi = !!(navigator.clipboard as any).write;
+
         return <div>
-            <div className='msp-image-preview'>
-                <img ref={this.imgRef} /><br />
-                <span>Right-click the image to Copy.</span>
-            </div>
+            {this.state.showPreview && <div className='msp-image-preview'>
+                <ScreenshotPreview plugin={this.plugin} />
+                <CropControls plugin={this.plugin} />
+            </div>}
             <div className='msp-flex-row'>
+                {hasClipboardApi && <Button icon={CopySvg} onClick={this.copy} disabled={this.state.isDisabled}>Copy</Button>}
+                {!hasClipboardApi && !this.state.imageData && <Button icon={CopySvg} onClick={this.copyImg} disabled={this.state.isDisabled}>Copy</Button>}
+                {this.state.imageData && <Button onClick={() => this.setState({ imageData: void 0 })} disabled={this.state.isDisabled}>Clear</Button>}
                 <Button icon={GetAppSvg} onClick={this.download} disabled={this.state.isDisabled}>Download</Button>
-                <Button icon={LaunchSvg} onClick={this.openTab} disabled={this.state.isDisabled}>Open in new Tab</Button>
             </div>
-            <ParameterControls params={this.plugin.helpers.viewportScreenshot!.params} values={this.plugin.helpers.viewportScreenshot!.values} onChange={this.setProps} isDisabled={this.state.isDisabled} />
+            {this.state.imageData && <div className='msp-row msp-copy-image-wrapper'>
+                <div>Right click below + Copy Image</div>
+                <img src={this.state.imageData} style={{ width: '100%', height: 32, display: 'block' }} />
+            </div>}
+            <ScreenshotParams plugin={this.plugin} isDisabled={this.state.isDisabled} />
             <ExpandGroup header='State'>
                 <StateExportImportControls onAction={this.props.close} />
                 <ExpandGroup header='Save Options' initiallyExpanded={false} noOffset>
@@ -152,4 +89,32 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
             </ExpandGroup>
         </div>;
     }
+}
+
+function ScreenshotParams({ plugin, isDisabled }: { plugin: PluginContext, isDisabled: boolean }) {
+    const helper = plugin.helpers.viewportScreenshot!;
+    const values = useBehavior(helper.behaviors.values);
+
+    return <ParameterControls params={helper.params} values={values} onChangeValues={v => helper.behaviors.values.next(v)} isDisabled={isDisabled} />;
+}
+
+function CropControls({ plugin }: { plugin: PluginContext }) {
+    const helper = plugin.helpers.viewportScreenshot;
+    const cropParams = useBehavior(helper?.behaviors.cropParams!);
+    useBehavior(helper?.behaviors.relativeCrop);
+
+    if (!helper) return null;
+
+    return <div style={{ width: '100%', height: '24px', marginTop: '8px' }}>
+        <ToggleButton icon={CropOrginalSvg} title='Auto-crop' inline isSelected={cropParams.auto}
+            style={{ background: 'transparent', float: 'left', width: 'auto', height: '24px', lineHeight: '24px' }}
+            toggle={() => helper.toggleAutocrop()} label={'Auto-crop ' + (cropParams.auto ? 'On' : 'Off')} />
+
+        {!cropParams.auto && <Button icon={CropSvg} title='Crop'
+            style={{ background: 'transparent', float: 'right', height: '24px', lineHeight: '24px', width: '24px', padding: '0' }}
+            onClick={() => helper.autocrop()} />}
+        {!cropParams.auto && !helper.isFullFrame && <Button icon={CropFreeSvg} title='Reset Crop'
+            style={{ background: 'transparent', float: 'right', height: '24px', lineHeight: '24px', width: '24px', padding: '0' }}
+            onClick={() => helper.resetCrop()} />}
+    </div>;
 }

+ 47 - 0
src/mol-plugin/animation-loop.ts

@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginContext } from './context';
+import { now } from '../mol-util/now';
+
+export class PluginAnimationLoop {
+    private currentFrame: any = void 0;
+    private _isAnimating = false;
+
+    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);
+    }
+
+    private frame = () => {
+        this.tick(now());
+        if (this._isAnimating) {
+            this.currentFrame = requestAnimationFrame(this.frame);
+        }
+    }
+
+    resetTime(t: number = now()) {
+        this.plugin.canvas3d?.resetTime(t);
+    }
+
+    start() {
+        this._isAnimating = true;
+        this.resetTime();
+        this.currentFrame = requestAnimationFrame(this.frame);
+    }
+
+    stop() {
+        this._isAnimating = false;
+        if (this.currentFrame !== void 0) {
+            cancelAnimationFrame(this.currentFrame);
+            this.currentFrame = void 0;
+        }
+    }
+
+    constructor(private plugin: PluginContext) {
+
+    }
+}

+ 4 - 2
src/mol-plugin/context.ts

@@ -60,9 +60,10 @@ import { objectForEach } from '../mol-util/object';
 import { VolumeHierarchyManager } from '../mol-plugin-state/manager/volume/hierarchy';
 import { filter, take } from 'rxjs/operators';
 import { Vec2 } from '../mol-math/linear-algebra';
+import { PluginAnimationLoop } from './animation-loop';
 
 export class PluginContext {
-    runTask = <T>(task: Task<T>) => this.managers.task.run(task);
+    runTask = <T>(task: Task<T>, params?: { useOverlay?: boolean }) => this.managers.task.run(task, params);
     resolveTask = <T>(object: Task<T> | T | undefined) => {
         if (!object) return void 0;
         if (Task.is(object)) return this.runTask(object);
@@ -104,6 +105,7 @@ export class PluginContext {
     } as const;
 
     readonly canvas3d: Canvas3D | undefined;
+    readonly animationLoop = new PluginAnimationLoop(this);
     readonly layout = new PluginLayout(this);
 
     readonly representation = {
@@ -204,7 +206,7 @@ export class PluginContext {
                 }
                 this.canvas3d?.setProps(props);
             }
-            this.canvas3d!.animate();
+            this.animationLoop.start();
             (this.helpers.viewportScreenshot as ViewportScreenshotHelper) = new ViewportScreenshotHelper(this);
             return true;
         } catch (e) {

+ 5 - 4
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: [
@@ -86,9 +88,8 @@ export const DefaultPluginSpec: PluginSpec = {
     ],
     animations: [
         AnimateModelIndex,
-        AnimateAssemblyUnwind,
-        AnimateUnitsExplode,
-        AnimateStateInterpolation
+        AnimateCameraSpin,
+        AnimateAssemblyUnwind
     ]
 };
 

+ 2 - 1
src/mol-plugin/spec.ts

@@ -32,7 +32,8 @@ interface PluginSpec {
             view?: React.ComponentClass,
             controls?: React.ComponentClass,
             canvas3d?: PartialCanvas3DProps
-        }
+        },
+        hideTaskOverlay?: boolean
     },
     config?: [PluginConfigItem, unknown][]
 }

+ 9 - 4
src/mol-plugin/util/task-manager.ts

@@ -17,6 +17,7 @@ class TaskManager {
     private id = 0;
     private runningTasks = new Set<number>();
     private abortRequests = new Map<number, string | undefined>();
+    private options = new Map<number, { useOverlay: boolean }>();
     private currentContext: { ctx: RuntimeContext, refCount: number }[] = [];
 
     readonly events = {
@@ -42,18 +43,19 @@ class TaskManager {
             const elapsed = now() - progress.root.progress.startedTime;
             this.events.progress.next({
                 id: internalId,
-                level: elapsed < 250 ? 'none' : elapsed < 1500 ? 'background' : 'overlay',
+                useOverlay: this.options.get(taskId)?.useOverlay,
+                level: elapsed < 250 ? 'none' : 'background',
                 progress
             });
         };
     }
 
-    async run<T>(task: Task<T>, createNewContext = false): Promise<T> {
+    async run<T>(task: Task<T>, params?: { createNewContext?: boolean, useOverlay?: boolean }): Promise<T> {
         const id = this.id++;
 
         let ctx: TaskManager['currentContext'][0];
 
-        if (createNewContext || this.currentContext.length === 0) {
+        if (params?.createNewContext || this.currentContext.length === 0) {
             ctx = { ctx: CreateObservableCtx(task, this.track(id, task.id), 100), refCount: 1 };
         } else {
             ctx = this.currentContext[this.currentContext.length - 1];
@@ -61,10 +63,12 @@ class TaskManager {
         }
 
         try {
+            this.options.set(task.id, { useOverlay: !!params?.useOverlay });
             this.runningTasks.add(task.id);
             const ret = await ExecuteInContext(ctx.ctx, task);
             return ret;
         } finally {
+            this.options.delete(task.id);
             this.runningTasks.delete(task.id);
             this.events.finished.next({ id });
             this.abortRequests.delete(task.id);
@@ -90,10 +94,11 @@ class TaskManager {
 }
 
 namespace TaskManager {
-    export type ReportLevel = 'none' | 'background' | 'overlay'
+    export type ReportLevel = 'none' | 'background'
 
     export interface ProgressEvent {
         id: number,
+        useOverlay?: boolean,
         level: ReportLevel,
         progress: Progress
     }

+ 236 - 76
src/mol-plugin/util/viewport-screenshot.ts

@@ -1,3 +1,4 @@
+import { Viewport } from '../../mol-canvas3d/camera/util';
 /**
  * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
@@ -5,26 +6,30 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { PluginContext } from '../context';
+import { CameraHelperParams } from '../../mol-canvas3d/helper/camera-helper';
 import { ImagePass } from '../../mol-canvas3d/passes/image';
-import { StateSelection } from '../../mol-state';
-import { PluginStateObject } from '../../mol-plugin-state/objects';
-import { Task, RuntimeContext } from '../../mol-task';
 import { canvasToBlob } from '../../mol-canvas3d/util';
+import { equalEps } from '../../mol-math/linear-algebra/3d/common';
+import { PluginComponent } from '../../mol-plugin-state/component';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { StateSelection } from '../../mol-state';
+import { RuntimeContext, Task } from '../../mol-task';
+import { Color } from '../../mol-util/color';
 import { download } from '../../mol-util/download';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
-import { SyncRuntimeContext } from '../../mol-task/execution/synchronous';
-import { CameraHelperParams, CameraHelperProps } from '../../mol-canvas3d/helper/camera-helper';
 import { SetUtils } from '../../mol-util/set';
+import { PluginContext } from '../context';
 
-export { ViewportScreenshotHelper };
+export { ViewportScreenshotHelper, ViewportScreenshotHelperParams };
 
 namespace ViewportScreenshotHelper {
     export type ResolutionSettings = PD.Values<ReturnType<ViewportScreenshotHelper['createParams']>>['resolution']
     export type ResolutionTypes = ResolutionSettings['name']
 }
 
-class ViewportScreenshotHelper {
+type ViewportScreenshotHelperParams = PD.Values<ReturnType<ViewportScreenshotHelper['createParams']>>
+
+class ViewportScreenshotHelper extends PluginComponent {
     private createParams() {
         const max = Math.min(this.plugin.canvas3d ? this.plugin.canvas3d.webgl.maxRenderbufferSize : 4096, 4096);
         return {
@@ -56,12 +61,30 @@ class ViewportScreenshotHelper {
         return this._params = this.createParams();
     }
 
+    readonly behaviors = {
+        values: this.ev.behavior<ViewportScreenshotHelperParams>({
+            transparent: this.params.transparent.defaultValue,
+            axes: { name: 'off', params: {} },
+            resolution: this.params.resolution.defaultValue
+        }),
+        cropParams: this.ev.behavior<{ auto: boolean, relativePadding: number }>({ auto: true, relativePadding: 0.1 }),
+        relativeCrop: this.ev.behavior<Viewport>({ x: 0, y: 0, width: 1, height: 1 }),
+    };
+
+    readonly events = {
+        previewed: this.ev<any>()
+    };
+
     get values() {
-        return {
-            transparent: this.transparent,
-            axes: this.axes,
-            resolution: this.currentResolution
-        };
+        return this.behaviors.values.value;
+    }
+
+    get cropParams() {
+        return this.behaviors.cropParams.value;
+    }
+
+    get relativeCrop() {
+        return this.behaviors.relativeCrop.value;
     }
 
     private getCanvasSize() {
@@ -71,44 +94,55 @@ class ViewportScreenshotHelper {
         };
     }
 
-    transparent = this.params.transparent.defaultValue
-    axes: CameraHelperProps['axes'] = { name: 'off', params: {} }
-    currentResolution = this.params.resolution.defaultValue
-
     private getSize() {
-        switch (this.currentResolution.name ) {
+        const values = this.values;
+        switch (values.resolution.name ) {
             case 'viewport': return this.getCanvasSize();
             case 'hd': return { width: 1280, height: 720 };
             case 'full-hd': return { width: 1920, height: 1080 };
             case 'ultra-hd': return { width: 3840, height: 2160 };
-            default: return { width: this.currentResolution.params.width, height: this.currentResolution.params.height };
+            default: return { width: values.resolution.params.width, height: values.resolution.params.height };
         }
     }
 
-    private _imagePass: ImagePass;
-
-    get imagePass() {
-        if (this._imagePass) return this._imagePass;
-
+    private createPass(mutlisample: boolean) {
         const c = this.plugin.canvas3d!;
-        this._imagePass = c.getImagePass({
-            transparentBackground: this.transparent,
-            cameraHelper: { axes: this.axes },
+        return this.plugin.canvas3d!.getImagePass({
+            transparentBackground: this.values.transparent,
+            cameraHelper: { axes: this.values.axes },
             multiSample: {
-                mode: 'on',
+                mode: mutlisample ? 'on' : 'off',
                 sampleLevel: c.webgl.extensions.colorBufferFloat ? 4 : 2
             },
             postprocessing: c.props.postprocessing
         });
-        return this._imagePass;
     }
 
-    getFilename() {
+    private _previewPass: ImagePass;
+    private get previewPass() {
+        return this._previewPass || (this._previewPass = this.createPass(false));
+    }
+
+    private _imagePass: ImagePass;
+    get imagePass() {
+        if (this._imagePass) {
+            this._imagePass.setProps({
+                cameraHelper: { axes: this.values.axes },
+                transparentBackground: this.values.transparent,
+                // TODO: optimize because this creates a copy of a large object!
+                postprocessing: this.plugin.canvas3d!.props.postprocessing
+            });
+            return this._imagePass;
+        }
+        return this._imagePass = this.createPass(true);
+    }
+
+    getFilename(extension = '.png') {
         const models = this.plugin.state.data.select(StateSelection.Generators.rootsOfType(PluginStateObject.Molecule.Model)).map(s => s.obj!.data);
         const uniqueIds = new Set<string>();
         models.forEach(m => uniqueIds.add(m.entryId.toUpperCase()));
         const idString = SetUtils.toArray(uniqueIds).join('-');
-        return `${idString || 'molstar-image'}.png`;
+        return `${idString || 'molstar-image'}${extension}`;
     }
 
     private canvas = function () {
@@ -116,39 +150,144 @@ class ViewportScreenshotHelper {
         return canvas;
     }();
 
-    // private preview = () => {
-    //     const { width, height } = this.getSize()
-    //     if (width <= 0 || height <= 0) return
-
-    //     let w: number, h: number
-    //     const aH = maxHeightUi / height
-    //     const aW = maxWidthUi / width
-    //     if (aH < aW) {
-    //         h = Math.round(Math.min(maxHeightUi, height))
-    //         w = Math.round(width * (h / height))
-    //     } else {
-    //         w = Math.round(Math.min(maxWidthUi, width))
-    //         h = Math.round(height * (w / width))
-    //     }
-    //     setCanvasSize(this.canvas, w, h)
-    //     const pixelRatio = this.plugin.canvas3d?.webgl.pixelRatio || 1
-    //     const pw = Math.round(w * pixelRatio)
-    //     const ph = Math.round(h * pixelRatio)
-    //     const imageData = this.imagePass.getImageData(pw, ph)
-    //     this.canvasContext.putImageData(imageData, 0, 0)
-    // }
+    private previewCanvas = function () {
+        const canvas = document.createElement('canvas');
+        return canvas;
+    }();
+
+    private previewData = {
+        image: { data: new Uint8ClampedArray(1), width: 1, height: 0 } as ImageData,
+        background: Color(0),
+        transparent: false
+    };
 
-    private async draw(ctx: RuntimeContext) {
+    resetCrop() {
+        this.behaviors.relativeCrop.next({ x: 0, y: 0, width: 1, height: 1 });
+    }
+
+    toggleAutocrop() {
+        if (this.cropParams.auto) {
+            this.behaviors.cropParams.next({ ...this.cropParams, auto: false });
+            this.resetCrop();
+        } else {
+            this.behaviors.cropParams.next({ ...this.cropParams, auto: true });
+        }
+    }
+
+    get isFullFrame() {
+        const crop = this.relativeCrop;
+        return equalEps(crop.x, 0, 1e-5) && equalEps(crop.y, 0, 1e-5) && equalEps(crop.width, 1, 1e-5) && equalEps(crop.height, 1, 1e-5);
+    }
+
+    autocrop(relativePadding = this.cropParams.relativePadding) {
+        const { data, width, height } = this.previewData.image;
+        const bgColor = this.previewData.transparent ? this.previewData.background : 0xff000000 | this.previewData.background;
+
+        let l = width, r = 0, t = height, b = 0;
+
+        for (let j = 0; j < height; j++) {
+            const jj = j * width;
+            for (let i = 0; i < width; i++) {
+                const o = 4 * (jj + i);
+                const c = (data[o] << 16) | (data[o + 1] << 8) | (data[o + 2]) | (data[o + 3] << 24);
+
+                if (c === bgColor) continue;
+
+                if (i < l) l = i;
+                if (i > r) r = i;
+                if (j < t) t = j;
+                if (j > b) b = j;
+            }
+        }
+
+        if (l > r) {
+            const x = l;
+            l = r;
+            r = x;
+        }
+
+        if (t > b) {
+            const x = t;
+            t = b;
+            b = x;
+        }
+
+        const tw = r - l + 1, th = b - t + 1;
+        l -= relativePadding * tw;
+        r += relativePadding * tw;
+        t -= relativePadding * th;
+        b += relativePadding * th;
+
+        const crop: Viewport = {
+            x: Math.max(0, l / width),
+            y: Math.max(0, t / height),
+            width: Math.min(1, (r - l + 1) / width),
+            height: Math.min(1, (b - t + 1) / height)
+        };
+
+        this.behaviors.relativeCrop.next(crop);
+    }
+
+    getPreview(maxDim = 320) {
         const { width, height } = this.getSize();
         if (width <= 0 || height <= 0) return;
 
-        await ctx.update('Rendering image...');
-        this.imagePass.setProps({
-            cameraHelper: { axes: this.axes },
-            transparentBackground: this.transparent,
-            postprocessing: this.plugin.canvas3d!.props.postprocessing // TODO this line should not be required, updating should work by listening to this.plugin.events.canvas3d.settingsUpdated
+        const f = width / height;
+
+        let w = 0, h = 0;
+        if (f > 1) {
+            w = maxDim;
+            h = Math.round(maxDim / f);
+        } else {
+            h = maxDim;
+            w = Math.round(maxDim * f);
+        }
+
+        const canvasProps = this.plugin.canvas3d!.props;
+        this.previewPass.setProps({
+            cameraHelper: { axes: this.values.axes },
+            transparentBackground: this.values.transparent,
+            // TODO: optimize because this creates a copy of a large object!
+            postprocessing: canvasProps.postprocessing
         });
-        const imageData = this.imagePass.getImageData(width, height);
+        const imageData = this.previewPass.getImageData(w, h);
+        const canvas = this.previewCanvas;
+        canvas.width = imageData.width;
+        canvas.height = imageData.height;
+
+        this.previewData.image = imageData;
+        this.previewData.background = canvasProps.renderer.backgroundColor;
+        this.previewData.transparent = this.values.transparent;
+
+        const canvasCtx = canvas.getContext('2d');
+        if (!canvasCtx) throw new Error('Could not create canvas 2d context');
+        canvasCtx.putImageData(imageData, 0, 0);
+        if (this.cropParams.auto) this.autocrop();
+
+        this.events.previewed.next();
+        return { canvas, width: w, height: h };
+    }
+
+    getSizeAndViewport() {
+        const { width, height } = this.getSize();
+        const crop = this.relativeCrop;
+        const viewport: Viewport = {
+            x: Math.floor(crop.x * width),
+            y: Math.floor(crop.y * height),
+            width: Math.ceil(crop.width * width),
+            height: Math.ceil(crop.height * height)
+        };
+        if (viewport.width + viewport.x > width) viewport.width = width - viewport.x;
+        if (viewport.height + viewport.y > height) viewport.height = height - viewport.y;
+        return { width, height, viewport };
+    }
+
+    private async draw(ctx: RuntimeContext) {
+        const { width, height, viewport } = this.getSizeAndViewport();
+        if (width <= 0 || height <= 0) return;
+
+        await ctx.update('Rendering image...');
+        const imageData = this.imagePass.getImageData(width, height, viewport);
 
         await ctx.update('Encoding image...');
         const canvas = this.canvas;
@@ -160,33 +299,54 @@ class ViewportScreenshotHelper {
         return;
     }
 
-    private downloadTask() {
-        return Task.create('Download Image', async ctx => {
-            this.draw(ctx);
-            await ctx.update('Downloading image...');
+    private copyToClipboardTask() {
+        const cb = navigator.clipboard as any;
+
+        if (!cb.write) {
+            this.plugin.log.error('clipboard.write not supported!');
+            return;
+        }
+
+        return Task.create('Copy Image', async ctx => {
+            await this.draw(ctx);
+            await ctx.update('Converting image...');
             const blob = await canvasToBlob(this.canvas, 'png');
-            download(blob, this.getFilename());
+            const item = new ClipboardItem({ 'image/png': blob });
+            cb.write([item]);
+            this.plugin.log.message('Image copied to clipboard.');
         });
     }
 
-    downloadCurrent() {
-        return this.plugin.runTask(Task.create('Download Image', async ctx => {
-            await ctx.update('Downloading image...');
-            const blob = await canvasToBlob(this.canvas, 'png');
-            download(blob, this.getFilename());
+    getImageDataUri() {
+        return this.plugin.runTask(Task.create('Generate Image', async ctx => {
+            await this.draw(ctx);
+            await ctx.update('Converting image...');
+            return this.canvas.toDataURL('png');
         }));
     }
 
-    async imageData() {
-        await this.draw(SyncRuntimeContext);
-        return this.canvas.toDataURL();
+    copyToClipboard() {
+        const task = this.copyToClipboardTask();
+        if (!task) return;
+        return this.plugin.runTask(task);
     }
 
-    download() {
-        this.plugin.runTask(this.downloadTask());
+    private downloadTask(filename?: string) {
+        return Task.create('Download Image', async ctx => {
+            await this.draw(ctx);
+            await ctx.update('Downloading image...');
+            const blob = await canvasToBlob(this.canvas, 'png');
+            download(blob, filename ?? this.getFilename());
+        });
     }
 
-    constructor(private plugin: PluginContext) {
+    download(filename?: string) {
+        this.plugin.runTask(this.downloadTask(filename));
+    }
 
+    constructor(private plugin: PluginContext) {
+        super();
     }
-}
+}
+
+declare const ClipboardItem: any;

+ 2 - 1
src/mol-theme/label.ts

@@ -203,7 +203,8 @@ export function _bundleLabel(bundle: Loci.Bundle<any>, options: LabelOptions) {
 export function elementLabel(location: StructureElement.Location, options: Partial<LabelOptions> = {}): string {
     const o = { ...DefaultLabelOptions, ...options };
     const _label = _elementLabel(location, o.granularity, o.hidePrefix, o.reverse || o.condensed);
-    const label = o.condensed ? _label[0].replace(/\[.*\]/g, '').trim() : _label.filter(l => !!l).join(' | ');
+    // TODO: condensed label for single atom structure returns empty label.. handle this case here?
+    const label = o.condensed ? _label[0]?.replace(/\[.*\]/g, '').trim() ?? '' : _label.filter(l => !!l).join(' | ');
     return o.htmlStyling ? label : stripTags(label);
 }
 

+ 3 - 0
src/servers/model/CHANGELOG.md

@@ -1,3 +1,6 @@
+# 0.9.4
+* bug fix for /ligand queries on metal ions
+
 # 0.9.3
 * optional transform parameter
 

+ 1 - 1
src/servers/model/version.ts

@@ -4,4 +4,4 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-export default '0.9.3';
+export default '0.9.4';