ソースを参照

created new example (based on Proteopedia Wrapper) for PLY file loading

MarcoSchaeferT 6 年 前
コミット
19cabfb446

+ 3 - 9
package-lock.json

@@ -14925,7 +14925,6 @@
               "version": "2.3.5",
               "bundled": true,
               "dev": true,
-              "optional": true,
               "requires": {
                 "safe-buffer": "^5.1.2",
                 "yallist": "^3.0.0"
@@ -14944,7 +14943,6 @@
               "version": "0.5.1",
               "bundled": true,
               "dev": true,
-              "optional": true,
               "requires": {
                 "minimist": "0.0.8"
               }
@@ -15038,7 +15036,6 @@
               "version": "1.4.0",
               "bundled": true,
               "dev": true,
-              "optional": true,
               "requires": {
                 "wrappy": "1"
               }
@@ -15124,8 +15121,7 @@
             "safe-buffer": {
               "version": "5.1.2",
               "bundled": true,
-              "dev": true,
-              "optional": true
+              "dev": true
             },
             "safer-buffer": {
               "version": "2.1.2",
@@ -15225,14 +15221,12 @@
             "wrappy": {
               "version": "1.0.2",
               "bundled": true,
-              "dev": true,
-              "optional": true
+              "dev": true
             },
             "yallist": {
               "version": "3.0.3",
               "bundled": true,
-              "dev": true,
-              "optional": true
+              "dev": true
             }
           }
         },

+ 72 - 0
src/examples/ply-wrapper/annotation.ts

@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { CustomElementProperty } from 'mol-model-props/common/custom-element-property';
+import { Model, ElementIndex, ResidueIndex } from 'mol-model/structure';
+import { Color } from 'mol-util/color';
+
+const EvolutionaryConservationPalette: Color[] = [
+    [255, 255, 129], // insufficient
+    [160, 37, 96], // 9
+    [240, 125, 171],
+    [250, 201, 222],
+    [252, 237, 244],
+    [255, 255, 255],
+    [234, 255, 255],
+    [215, 255, 255],
+    [140, 255, 255],
+    [16, 200, 209] // 1
+].reverse().map(([r, g, b]) => Color.fromRgb(r, g, b));
+const EvolutionaryConservationDefaultColor = Color(0x999999);
+
+export const EvolutionaryConservation = CustomElementProperty.create<number>({
+    isStatic: true,
+    name: 'proteopedia-wrapper-evolutionary-conservation',
+    display: 'Evolutionary Conservation',
+    async getData(model: Model) {
+        const id = model.label.toLowerCase();
+        const req = await fetch(`https://proteopedia.org/cgi-bin/cnsrf?${id}`);
+        const json = await req.json();
+        const annotations = (json && json.residueAnnotations) || [];
+
+        const conservationMap = new Map<string, number>();
+
+        for (const e of annotations) {
+            for (const r of e.ids) {
+                conservationMap.set(r, e.annotation);
+            }
+        }
+
+        const map = new Map<ElementIndex, number>();
+
+        const { _rowCount: residueCount } = model.atomicHierarchy.residues;
+        const { offsets: residueOffsets } = model.atomicHierarchy.residueAtomSegments;
+        const chainIndex = model.atomicHierarchy.chainAtomSegments.index;
+
+        for (let rI = 0 as ResidueIndex; rI < residueCount; rI++) {
+            const cI = chainIndex[residueOffsets[rI]];
+            const key = `${model.atomicHierarchy.chains.auth_asym_id.value(cI)} ${model.atomicHierarchy.residues.auth_seq_id.value(rI)}`;
+            if (!conservationMap.has(key)) continue;
+            const ann = conservationMap.get(key)!;
+            for (let aI = residueOffsets[rI]; aI < residueOffsets[rI + 1]; aI++) {
+                map.set(aI, ann);
+            }
+        }
+
+        return map;
+    },
+    coloring: {
+        getColor(e: number) {
+            if (e < 1 || e > 10) return EvolutionaryConservationDefaultColor;
+            return EvolutionaryConservationPalette[e - 1];
+        },
+        defaultColor: EvolutionaryConservationDefaultColor
+    },
+    format(e) {
+        if (e === 10) return `Evolutionary Conservation: InsufficientData`;
+        return e ? `Evolutionary Conservation: ${e}` : void 0;
+    }
+});

+ 7 - 0
src/examples/ply-wrapper/changelog.md

@@ -0,0 +1,7 @@
+== v2.0 ==
+
+* Changed how state saving works.
+
+== v1.0 ==
+
+* Initial version.

+ 101 - 0
src/examples/ply-wrapper/helpers.ts

@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { ResidueIndex, Model } from 'mol-model/structure';
+import { BuiltInStructureRepresentationsName } from 'mol-repr/structure/registry';
+import { BuiltInColorThemeName } from 'mol-theme/color';
+import { AminoAcidNames } from 'mol-model/structure/model/types';
+import { PluginContext } from 'mol-plugin/context';
+
+export interface ModelInfo {
+    hetResidues: { name: string, indices: ResidueIndex[] }[],
+    assemblies: { id: string, details: string, isPreferred: boolean }[],
+    preferredAssemblyId: string | undefined
+}
+
+export namespace ModelInfo {
+    async function getPreferredAssembly(ctx: PluginContext, model: Model) {
+        if (model.label.length <= 3) return void 0;
+        try {
+            const id = model.label.toLowerCase();
+            const src = await ctx.runTask(ctx.fetch({ url: `https://www.ebi.ac.uk/pdbe/api/pdb/entry/summary/${id}` })) as string;
+            const json = JSON.parse(src);
+            const data = json && json[id];
+
+            const assemblies = data[0] && data[0].assemblies;
+            if (!assemblies || !assemblies.length) return void 0;
+
+            for (const asm of assemblies) {
+                if (asm.preferred) {
+                    return asm.assembly_id;
+                }
+            }
+            return void 0;
+        } catch (e) {
+            console.warn('getPreferredAssembly', e);
+        }
+    }
+
+    export async function get(ctx: PluginContext, model: Model, checkPreferred: boolean): Promise<ModelInfo> {
+        const { _rowCount: residueCount } = model.atomicHierarchy.residues;
+        const { offsets: residueOffsets } = model.atomicHierarchy.residueAtomSegments;
+        const chainIndex = model.atomicHierarchy.chainAtomSegments.index;
+        // const resn = SP.residue.label_comp_id, entType = SP.entity.type;
+
+        const pref = checkPreferred
+            ? getPreferredAssembly(ctx, model)
+            : void 0;
+
+        const hetResidues: ModelInfo['hetResidues'] = [];
+        const hetMap = new Map<string, ModelInfo['hetResidues'][0]>();
+
+        for (let rI = 0 as ResidueIndex; rI < residueCount; rI++) {
+            const comp_id = model.atomicHierarchy.residues.label_comp_id.value(rI);
+            if (AminoAcidNames.has(comp_id)) continue;
+            const mod_parent = model.properties.modifiedResidues.parentId.get(comp_id);
+            if (mod_parent && AminoAcidNames.has(mod_parent)) continue;
+
+            const cI = chainIndex[residueOffsets[rI]];
+            const eI = model.atomicHierarchy.index.getEntityFromChain(cI);
+            if (model.entities.data.type.value(eI) === 'water') continue;
+
+            let lig = hetMap.get(comp_id);
+            if (!lig) {
+                lig = { name: comp_id, indices: [] };
+                hetResidues.push(lig);
+                hetMap.set(comp_id, lig);
+            }
+            lig.indices.push(rI);
+        }
+
+        const preferredAssemblyId = await pref;
+
+        return {
+            hetResidues: hetResidues,
+            assemblies: model.symmetry.assemblies.map(a => ({ id: a.id, details: a.details, isPreferred: a.id === preferredAssemblyId })),
+            preferredAssemblyId
+        };
+    }
+}
+
+export type SupportedFormats = 'cif' | 'pdb'
+export interface LoadParams {
+    plyurl: string,
+    url: string,
+    format?: SupportedFormats,
+    assemblyId?: string,
+    representationStyle?: RepresentationStyle
+}
+
+export interface RepresentationStyle {
+    sequence?: RepresentationStyle.Entry,
+    hetGroups?: RepresentationStyle.Entry,
+    water?: RepresentationStyle.Entry
+}
+
+export namespace RepresentationStyle {
+    export type Entry = { kind?: BuiltInStructureRepresentationsName, coloring?: BuiltInColorThemeName }
+}

+ 161 - 0
src/examples/ply-wrapper/index.html

@@ -0,0 +1,161 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+        <title>Mol* PLY Wrapper</title>
+        <style>
+            * {
+                margin: 0;
+                padding: 0;
+                box-sizing: border-box;
+            }
+            #app {
+                position: absolute;
+                left: 160px;
+                top: 100px;
+                width: 600px;
+                height: 400px;
+                border: 1px solid #ccc;
+            }
+
+            #controls {
+                position: absolute;
+                width: 130px;
+                top: 10px;
+                left: 10px;
+            }
+
+            #controls > button {
+                display: block;
+                width: 100%;
+                text-align: left;
+            }
+
+            #controls > hr {
+                margin: 5px 0;
+            }
+
+            #controls > input, #controls > select {
+                width: 100%;
+                display: block;
+            }
+        </style>
+        <link rel="stylesheet" type="text/css" href="app.css" />
+        <script type="text/javascript" src="./index.js"></script>
+    </head>
+    <body>
+        <div id='controls'>
+            <h3>Source</h3>
+            <input type='text' id='plyurl' placeholder='plyurl' style='width: 400px' />
+            <input type='text' id='url' placeholder='url' style='width: 400px' />
+            <input type='text' id='assemblyId' placeholder='assembly id' />
+            <select id='format'>
+                <option value='cif' selected>CIF</option>
+                <option value='pdb'>PDB</option>
+            </select>
+        </div>
+        <div id="app"></div>
+        <script>  
+            // create an instance of the plugin
+            var PluginWrapper = new MolStarPLYWrapper();
+
+            console.log('Wrapper version', MolStarPLYWrapper.VERSION_MAJOR);
+
+            function $(id) { return document.getElementById(id); }
+        
+            var pdbId = '1rwe', assemblyId= 'preferred';
+            var url = '/test-data/' + pdbId + '_updated.cif';
+            var format = 'cif';
+
+            var plyName = 'run_0_mesh';
+            var plyurl = '/test-data/' + plyName + '.ply';
+
+            $('plyurl').value = plyurl;
+            $('plyurl').onchange = function (e) { url = e.target.value; }
+            $('url').value = url;
+            $('url').onchange = function (e) { url = e.target.value; }
+            $('assemblyId').value = assemblyId;
+            $('assemblyId').onchange = function (e) { assemblyId = e.target.value; }
+            $('format').value = format;
+            $('format').onchange = function (e) { format = e.target.value; }
+
+            // var url = 'https://www.ebi.ac.uk/pdbe/entry-files/pdb' + pdbId + '.ent';
+            // var format = 'pdb';
+            // var assemblyId = 'deposited';
+
+            PluginWrapper.init('app' /** or document.getElementById('app') */);
+            PluginWrapper.setBackground(0xffffff);
+            PluginWrapper.load({ plyurl: plyurl, url: url, format: format, assemblyId: assemblyId });
+            PluginWrapper.toggleSpin();
+
+            PluginWrapper.events.modelInfo.subscribe(function (info) {
+                console.log('Model Info', info);
+            });
+
+            addControl('Load Asym Unit', () => PluginWrapper.load({ plyurl: plyurl, url: url, format: format }));
+            addControl('Load Assembly', () => PluginWrapper.load({ plyurl: plyurl, url: url, format: format, assemblyId: assemblyId }));
+
+            addSeparator();
+
+            addHeader('Camera');
+            addControl('Toggle Spin', () => PluginWrapper.toggleSpin());
+            
+            addSeparator();
+
+            addHeader('Animation');
+
+            // adjust this number to make the animation faster or slower
+            // requires to "restart" the animation if changed
+            PluginWrapper.animate.modelIndex.maxFPS = 30;
+
+            addControl('Play To End', () => PluginWrapper.animate.modelIndex.onceForward());
+            addControl('Play To Start', () => PluginWrapper.animate.modelIndex.onceBackward());
+            addControl('Play Palindrome', () => PluginWrapper.animate.modelIndex.palindrome());
+            addControl('Play Loop', () => PluginWrapper.animate.modelIndex.loop());
+            addControl('Stop', () => PluginWrapper.animate.modelIndex.stop());
+
+            addSeparator();
+            addHeader('Misc');
+
+            addControl('Apply Evo Cons', () => PluginWrapper.coloring.evolutionaryConservation());
+            addControl('Default Visuals', () => PluginWrapper.updateStyle());
+
+            addSeparator();
+            addHeader('State');
+
+            var snapshot;
+            addControl('Create Snapshot', () => {
+                snapshot = PluginWrapper.snapshot.get();
+                // could use JSON.stringify(snapshot) and upload the data
+            });
+            addControl('Apply Snapshot', () => {
+                if (!snapshot) return;
+                PluginWrapper.snapshot.set(snapshot);
+
+                // or download snapshot using fetch or ajax or whatever
+                // or PluginWrapper.snapshot.download(url);
+            });
+
+            ////////////////////////////////////////////////////////
+
+            function addControl(label, action) {
+                var btn = document.createElement('button');
+                btn.onclick = action;
+                btn.innerText = label;
+                $('controls').appendChild(btn);
+            }
+
+            function addSeparator() {
+                var hr = document.createElement('hr');
+                $('controls').appendChild(hr);
+            }
+
+            function addHeader(header) {
+                var h = document.createElement('h3');
+                h.innerText = header;
+                $('controls').appendChild(h);
+            }
+        </script>
+    </body>
+</html>

+ 235 - 0
src/examples/ply-wrapper/index.ts

@@ -0,0 +1,235 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { createPlugin, DefaultPluginSpec } from 'mol-plugin';
+import './index.html'
+import { PluginContext } from 'mol-plugin/context';
+import { PluginCommands } from 'mol-plugin/command';
+import { StateTransforms } from 'mol-plugin/state/transforms';
+import { StructureRepresentation3DHelpers } from 'mol-plugin/state/transforms/representation';
+import { Color } from 'mol-util/color';
+import { PluginStateObject as PSO, PluginStateObject } from 'mol-plugin/state/objects';
+import { AnimateModelIndex } from 'mol-plugin/state/animation/built-in';
+import {StateBuilder, StateObject} from 'mol-state';
+import { EvolutionaryConservation } from './annotation';
+import { LoadParams, SupportedFormats, RepresentationStyle, ModelInfo } from './helpers';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
+import { ControlsWrapper } from './ui/controls';
+import { PluginState } from 'mol-plugin/state';
+require('mol-plugin/skin/light.scss')
+
+class MolStarPLYWrapper {
+    static VERSION_MAJOR = 2;
+    static VERSION_MINOR = 0;
+
+    private _ev = RxEventHelper.create();
+
+    readonly events = {
+        modelInfo: this._ev<ModelInfo>()
+    };
+
+    plugin: PluginContext;
+
+    init(target: string | HTMLElement) {
+        this.plugin = createPlugin(typeof target === 'string' ? document.getElementById(target)! : target, {
+            ...DefaultPluginSpec,
+            layout: {
+                initial: {
+                    isExpanded: false,
+                    showControls: false
+                },
+                controls: {
+                    right: ControlsWrapper
+                }
+            }
+        });
+
+        this.plugin.structureRepresentation.themeCtx.colorThemeRegistry.add(EvolutionaryConservation.Descriptor.name, EvolutionaryConservation.colorTheme!);
+        this.plugin.lociLabels.addProvider(EvolutionaryConservation.labelProvider);
+        this.plugin.customModelProperties.register(EvolutionaryConservation.propertyProvider);
+    }
+
+    get state() {
+        return this.plugin.state.dataState;
+    }
+
+    private download(b: StateBuilder.To<PSO.Root>, url: string) {
+        return b.apply(StateTransforms.Data.Download, { url, isBinary: false })
+    }
+
+    private model(b: StateBuilder.To<PSO.Data.Binary | PSO.Data.String>, format: SupportedFormats, assemblyId: string) {
+        const parsed = format === 'cif'
+            ? b.apply(StateTransforms.Data.ParseCif).apply(StateTransforms.Model.TrajectoryFromMmCif)
+            : b.apply(StateTransforms.Model.TrajectoryFromPDB);
+
+        return parsed
+            .apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 }, { ref: 'model' });
+    }
+
+    private plyData(b: StateBuilder.To<PSO.Data.String>) {
+        return b.apply(StateTransforms.Data.ParsePly)
+            .apply(StateTransforms.Model.ShapeFromPly)
+            .apply(StateTransforms.Representation.ShapeRepresentation3D);
+    }
+
+    private structure(assemblyId: string) {
+        const model = this.state.build().to('model');
+
+        return model
+            .apply(StateTransforms.Model.CustomModelProperties, { properties: [EvolutionaryConservation.Descriptor.name] }, { ref: 'props', props: { isGhost: false } })
+            .apply(StateTransforms.Model.StructureAssemblyFromModel, { id: assemblyId || 'deposited' }, { ref: 'asm' });
+    }
+
+    private visual(ref: string, style?: RepresentationStyle) {
+        const structure = this.getObj<PluginStateObject.Molecule.Structure>(ref);
+        if (!structure) return;
+
+        const root = this.state.build().to(ref);
+
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'atomic-sequence' }, { ref: 'sequence' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsWithTheme(this.plugin,
+                    (style && style.sequence && style.sequence.kind) || 'cartoon',
+                    (style && style.sequence && style.sequence.coloring) || 'unit-index', structure),
+                    { ref: 'sequence-visual' });
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'atomic-het' }, { ref: 'het' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsWithTheme(this.plugin,
+                    (style && style.hetGroups && style.hetGroups.kind) || 'ball-and-stick',
+                    (style && style.hetGroups && style.hetGroups.coloring), structure),
+                    { ref: 'het-visual' });
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'water' }, { ref: 'water' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsWithTheme(this.plugin,
+                    (style && style.water && style.water.kind) || 'ball-and-stick',
+                    (style && style.water && style.water.coloring), structure, { alpha: 0.51 }),
+                    { ref: 'water-visual' });
+
+        return root;
+    }
+
+    private getObj<T extends StateObject>(ref: string): T['data'] {
+        const state = this.state;
+        const cell = state.select(ref)[0];
+        if (!cell || !cell.obj) return void 0;
+        return (cell.obj as T).data;
+    }
+
+    private async doInfo(checkPreferredAssembly: boolean) {
+        const model = this.getObj<PluginStateObject.Molecule.Model>('model');
+        if (!model) return;
+
+        const info = await ModelInfo.get(this.plugin, model, checkPreferredAssembly)
+        this.events.modelInfo.next(info);
+        return info;
+    }
+
+    private applyState(tree: StateBuilder) {
+        return PluginCommands.State.Update.dispatch(this.plugin, { state: this.plugin.state.dataState, tree });
+    }
+
+    private loadedParams: LoadParams = { plyurl: '',  url: '', format: 'cif', assemblyId: '' };
+    async load({ plyurl, url, format = 'cif', assemblyId = '', representationStyle }: LoadParams) {
+        let loadType: 'full' | 'update' = 'full';
+
+        const state = this.plugin.state.dataState;
+
+        if (this.loadedParams.plyurl !== plyurl || this.loadedParams.url !== url || this.loadedParams.format !== format) {
+            loadType = 'full';
+        } else if (this.loadedParams.url === url) {
+            if (state.select('asm').length > 0) loadType = 'update';
+        }
+
+        if (loadType === 'full') {
+            await PluginCommands.State.RemoveObject.dispatch(this.plugin, { state, ref: state.tree.root.ref });
+            // pdb/cif loading
+            const modelTree = this.model(this.download(state.build().toRoot(), url), format, assemblyId);
+            await this.applyState(modelTree);
+            const info = await this.doInfo(true);
+            const structureTree = this.structure((assemblyId === 'preferred' && info && info.preferredAssemblyId) || assemblyId);
+            await this.applyState(structureTree);
+            // ply loading
+            const modelTreePly = this.plyData(this.download(state.build().toRoot(), plyurl));
+            await this.applyState(modelTreePly);
+        } else {
+            const tree = state.build();
+            tree.to('asm').update(StateTransforms.Model.StructureAssemblyFromModel, p => ({ ...p, id: assemblyId || 'deposited' }));
+            await this.applyState(tree);
+        }
+
+        await this.updateStyle(representationStyle);
+
+        this.loadedParams = { plyurl, url, format, assemblyId };
+        PluginCommands.Camera.Reset.dispatch(this.plugin, { });
+    }
+
+    async updateStyle(style?: RepresentationStyle) {
+        const tree = this.visual('asm', style);
+        if (!tree) return;
+        await PluginCommands.State.Update.dispatch(this.plugin, { state: this.plugin.state.dataState, tree });
+    }
+
+    setBackground(color: number) {
+        PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { backgroundColor: Color(color) } });
+    }
+
+    toggleSpin() {
+        const trackball = this.plugin.canvas3d.props.trackball;
+        const spinning = trackball.spin;
+        PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { trackball: { ...trackball, spin: !trackball.spin } } });
+        if (!spinning) PluginCommands.Camera.Reset.dispatch(this.plugin, { });
+    }
+
+    animate = {
+        modelIndex: {
+            maxFPS: 8,
+            onceForward: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'once', params: { direction: 'forward' } } }) },
+            onceBackward: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'once', params: { direction: 'backward' } } }) },
+            palindrome: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'palindrome', params: {} } }) },
+            loop: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'loop', params: {} } }) },
+            stop: () => this.plugin.state.animation.stop()
+        }
+    }
+
+    coloring = {
+        evolutionaryConservation: async () => {
+            await this.updateStyle({ sequence: { kind: 'spacefill' } });
+
+            const state = this.state;
+
+            // const visuals = state.selectQ(q => q.ofType(PluginStateObject.Molecule.Structure.Representation3D).filter(c => c.transform.transformer === StateTransforms.Representation.StructureRepresentation3D));
+            const tree = state.build();
+            const colorTheme = { name: EvolutionaryConservation.Descriptor.name, params: this.plugin.structureRepresentation.themeCtx.colorThemeRegistry.get(EvolutionaryConservation.Descriptor.name).defaultValues };
+
+            tree.to('sequence-visual').update(StateTransforms.Representation.StructureRepresentation3D, old => ({ ...old, colorTheme }));
+            // for (const v of visuals) {
+            // }
+
+            await PluginCommands.State.Update.dispatch(this.plugin, { state, tree });
+        }
+    }
+
+    snapshot = {
+        get: () => {
+            return this.plugin.state.getSnapshot();
+        },
+        set: (snapshot: PluginState.Snapshot) => {
+            return this.plugin.state.setSnapshot(snapshot);
+        },
+        download: async (url: string) => {
+            try {
+                const data = await this.plugin.runTask(this.plugin.fetch({ url }));
+                const snapshot = JSON.parse(data);
+                await this.plugin.state.setSnapshot(snapshot);
+            } catch (e) {
+                console.log(e);
+            }
+        }
+
+    }
+}
+
+(window as any).MolStarPLYWrapper = MolStarPLYWrapper;

+ 21 - 0
src/examples/ply-wrapper/ui/controls.tsx

@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { PluginUIComponent } from 'mol-plugin/ui/base';
+import { CurrentObject } from 'mol-plugin/ui/plugin';
+import { AnimationControls } from 'mol-plugin/ui/state/animation';
+import { CameraSnapshots } from 'mol-plugin/ui/camera';
+
+export class ControlsWrapper extends PluginUIComponent {
+    render() {
+        return <div className='msp-scrollable-container msp-right-controls'>
+            <CurrentObject />
+            <AnimationControls />
+            <CameraSnapshots />
+        </div>;
+    }
+}

+ 2 - 2
src/mol-io/reader/ply/parser.ts

@@ -196,8 +196,8 @@ function moveNextInternal(state: State) {
                     if (state.currentProperty >= 3 && state.currentProperty < 6) {
                         state.colors[state.currentVertex * 3 + state.currentProperty - 3] = fastParseInt(state.tokenizer.data, state.tokenizer.tokenStart, state.tokenizer.tokenEnd);
                     }
-                    if (state.currentProperty >= 6 && state.currentProperty < 9) {
-                        state.normals[state.currentVertex * 3 + state.currentProperty - 6] = fastParseFloat(state.tokenizer.data, state.tokenizer.tokenStart, state.tokenizer.tokenEnd);
+                    if (state.currentProperty >= 7 && state.currentProperty < 10) {
+                        state.normals[state.currentVertex * 3 + state.currentProperty - 7] = fastParseFloat(state.tokenizer.data, state.tokenizer.tokenStart, state.tokenizer.tokenEnd);
                     }
                     state.currentProperty++;
                     if (state.currentProperty === state.propertyCount) {

+ 1 - 0
webpack.config.js

@@ -97,6 +97,7 @@ module.exports = [
     createApp('viewer'),
     createApp('basic-wrapper'),
     createEntry('examples/proteopedia-wrapper/index', 'examples/proteopedia-wrapper', 'index'),
+    createEntry('examples/ply-wrapper/index', 'examples/ply-wrapper', 'index'),
     createNodeApp('state-docs'),
     createNodeEntryPoint('preprocess', 'servers/model', 'model-server'),
     createApp('model-server-query'),