瀏覽代碼

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

Alexander Rose 3 年之前
父節點
當前提交
81e29533dc

+ 6 - 2
CHANGELOG.md

@@ -5,7 +5,11 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
-- [empty]
+- Add glTF (GLB) and STL support to ``geo-export`` extension.
+- Protein crosslink improvements
+    - Change O-S bond distance to allow for NOS bridges (doi:10.1038/s41586-021-03513-3)
+    - Added NOS-bridges query & improved disulfide-bridges query
+- Fix #178: ``IndexPairBonds`` for non-single residue structures (bug due to atom reordering).
 
 ## [v2.0.5] - 2021-04-26
 
@@ -19,7 +23,7 @@ Note that since we don't clearly distinguish between a public and private interf
 - Add `Torus` primitive.
 - Lazy volume loading support.
 - [Breaking] ``Viewer.loadVolumeFromUrl`` signature change.
-  - ``loadVolumeFromUrl(url, format, isBinary, isovalues, entryId)`` => ``loadVolumeFromUrl({ url, format, isBinary }, isovalues, { entryId, isLazy })``
+    - ``loadVolumeFromUrl(url, format, isBinary, isovalues, entryId)`` => ``loadVolumeFromUrl({ url, format, isBinary }, isovalues, { entryId, isLazy })``
 - Add ``TextureMesh`` support to ``geo-export`` extension.
 
 ## [v2.0.4] - 2021-04-20

+ 3 - 0
README.md

@@ -7,6 +7,9 @@
 
 The goal of **Mol\*** (*/'mol-star/*) is to provide a technology stack that serves as a basis for the next-generation data delivery and analysis tools for (not only) macromolecular structure data. Mol* development was jointly initiated by PDBe and RCSB PDB to combine and build on the strengths of [LiteMol](https://litemol.org) (developed by PDBe) and [NGL](https://nglviewer.org) (developed by RCSB PDB) viewers.
 
+When using Mol*, please cite:
+
+David Sehnal, Sebastian Bittrich, Mandar Deshpande, Radka Svobodová, Karel Berka, Václav Bazgier, Sameer Velankar, Stephen K Burley, Jaroslav Koča, Alexander S Rose: [Mol* Viewer: modern web app for 3D visualization and analysis of large biomolecular structures](https://doi.org/10.1093/nar/gkab314), *Nucleic Acids Research*, 2021; https://doi.org/10.1093/nar/gkab314.
 
 ## Project Structure Overview
 

+ 1 - 0
docs/interesting-pdb-entries.md

@@ -28,3 +28,4 @@
 * Multiple models with different sets of ligands or missing ligands (1J6T, 1VRC, 2ICY, 1O2F)
 * Long linear sugar chain (4HG6)
 * Anisotropic B-factors/Ellipsoids (1EJG)
+* NOS bridges (LYS-CSO in 7B0L, 6ZWJ, 6ZWH)

+ 38 - 26
src/extensions/geo-export/controls.ts

@@ -7,14 +7,28 @@
 import { PluginComponent } from '../../mol-plugin-state/component';
 import { PluginContext } from '../../mol-plugin/context';
 import { Task } from '../../mol-task';
-import { ObjExporter } from './export';
 import { PluginStateObject } from '../../mol-plugin-state/objects';
 import { StateSelection } from '../../mol-state';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { SetUtils } from '../../mol-util/set';
-import { zip } from '../../mol-util/zip/zip';
+import { ObjExporter } from './obj-exporter';
+import { GlbExporter } from './glb-exporter';
+import { StlExporter } from './stl-exporter';
+
+export const GeometryParams = {
+    format: PD.Select('glb', [
+        ['glb', 'glTF 2.0 Binary (.glb)'],
+        ['stl', 'Stl (.stl)'],
+        ['obj', 'Wavefront (.obj)']
+    ])
+};
 
 export class GeometryControls extends PluginComponent {
-    getFilename() {
+    readonly behaviors = {
+        params: this.ev.behavior<PD.Values<typeof GeometryParams>>(PD.getDefaultValues(GeometryParams))
+    }
+
+    private getFilename() {
         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()));
@@ -22,37 +36,35 @@ export class GeometryControls extends PluginComponent {
         return `${idString || 'molstar-model'}`;
     }
 
-    exportObj() {
-        const task = Task.create('Export OBJ', async ctx => {
+    exportGeometry() {
+        const task = Task.create('Export Geometry', async ctx => {
             try {
                 const renderObjects = this.plugin.canvas3d?.getRenderObjects()!;
-
                 const filename = this.getFilename();
-                const objExporter = new ObjExporter(filename);
+
+                let renderObjectExporter: ObjExporter | GlbExporter | StlExporter;
+                switch (this.behaviors.params.value.format) {
+                    case 'obj':
+                        renderObjectExporter = new ObjExporter(filename);
+                        break;
+                    case 'glb':
+                        renderObjectExporter = new GlbExporter();
+                        break;
+                    case 'stl':
+                        renderObjectExporter = new StlExporter();
+                        break;
+                    default: throw new Error('Unsupported format.');
+                }
+
                 for (let i = 0, il = renderObjects.length; i < il; ++i) {
                     await ctx.update({ message: `Exporting object ${i}/${il}` });
-                    await objExporter.add(renderObjects[i], this.plugin.canvas3d?.webgl!, ctx);
+                    await renderObjectExporter.add(renderObjects[i], this.plugin.canvas3d?.webgl!, ctx);
                 }
-                const { obj, mtl } = objExporter.getData();
 
-                const asciiWrite = (data: Uint8Array, str: string) => {
-                    for (let i = 0, il = str.length; i < il; ++i) {
-                        data[i] = str.charCodeAt(i);
-                    }
-                };
-                const objData = new Uint8Array(obj.length);
-                asciiWrite(objData, obj);
-                const mtlData = new Uint8Array(mtl.length);
-                asciiWrite(mtlData, mtl);
-
-                const zipDataObj = {
-                    [filename + '.obj']: objData,
-                    [filename + '.mtl']: mtlData
-                };
-                const zipData = await zip(ctx, zipDataObj);
+                const blob = await renderObjectExporter.getBlob(ctx);
                 return {
-                    zipData,
-                    filename: filename + '.zip'
+                    blob,
+                    filename: filename + '.' + renderObjectExporter.fileExtension
                 };
             } catch (e) {
                 this.plugin.log.error('' + e);

+ 314 - 0
src/extensions/geo-export/glb-exporter.ts

@@ -0,0 +1,314 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
+ */
+
+import { BaseValues } from '../../mol-gl/renderable/schema';
+import { asciiWrite } from '../../mol-io/common/ascii';
+import { IsNativeEndianLittle, flipByteOrder } from '../../mol-io/common/binary';
+import { Vec3, Mat3, Mat4 } from '../../mol-math/linear-algebra';
+import { RuntimeContext } from '../../mol-task';
+import { Color } from '../../mol-util/color/color';
+import { arrayMinMax, fillSerial } from '../../mol-util/array';
+import { NumberArray } from '../../mol-util/type-helpers';
+import { MeshExporter } from './mesh-exporter';
+
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3fromArray = Vec3.fromArray;
+const v3transformMat4 = Vec3.transformMat4;
+const v3transformMat3 = Vec3.transformMat3;
+const v3normalize = Vec3.normalize;
+const v3toArray = Vec3.toArray;
+const mat3directionTransform = Mat3.directionTransform;
+
+// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0
+
+export type GlbData = {
+    glb: Uint8Array
+}
+
+export class GlbExporter extends MeshExporter<GlbData> {
+    readonly fileExtension = 'glb';
+    private primitives: Record<string, any>[] = [];
+    private accessors: Record<string, any>[] = [];
+    private bufferViews: Record<string, any>[] = [];
+    private binaryBuffer: ArrayBuffer[] = [];
+    private byteOffset = 0;
+
+    private static vecMinMax(a: NumberArray, length: number) {
+        const min: number[] = (new Array(length)).fill(Infinity);
+        const max: number[] = (new Array(length)).fill(-Infinity);
+        for (let i = 0, il = a.length; i < il; i += length) {
+            for (let j = 0; j < length; ++j) {
+                min[j] = Math.min(a[i + j], min[j]);
+                max[j] = Math.max(a[i + j], max[j]);
+            }
+        }
+        return [ min, max ];
+    }
+
+    protected async addMeshWithColors(vertices: Float32Array, normals: Float32Array, indices: Uint32Array | undefined, groups: Float32Array | Uint8Array, vertexCount: number, drawCount: number, values: BaseValues, instanceIndex: number, isGeoTexture: boolean, ctx: RuntimeContext) {
+        const t = Mat4();
+        const n = Mat3();
+        const tmpV = Vec3();
+        const stride = isGeoTexture ? 4 : 3;
+
+        const groupCount = values.uGroupCount.ref.value;
+        const colorType = values.dColorType.ref.value;
+        const tColor = values.tColor.ref.value.array;
+        const uAlpha = values.uAlpha.ref.value;
+        const dTransparency = values.dTransparency.ref.value;
+        const tTransparency = values.tTransparency.ref.value;
+        const aTransform = values.aTransform.ref.value;
+
+        Mat4.fromArray(t, aTransform, instanceIndex * 16);
+        mat3directionTransform(n, t);
+
+        const currentProgress = (vertexCount * 3) * instanceIndex;
+        await ctx.update({ isIndeterminate: false, current: currentProgress, max: (vertexCount * 3) * values.uInstanceCount.ref.value });
+
+        const vertexArray = new Float32Array(vertexCount * 3);
+        const normalArray = new Float32Array(vertexCount * 3);
+        const colorArray = new Float32Array(vertexCount * 4);
+        let indexArray: Uint32Array;
+
+        // position
+        for (let i = 0; i < vertexCount; ++i) {
+            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + i });
+            v3transformMat4(tmpV, v3fromArray(tmpV, vertices, i * stride), t);
+            v3toArray(tmpV, vertexArray, i * 3);
+        }
+
+        // normal
+        for (let i = 0; i < vertexCount; ++i) {
+            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount + i });
+            v3fromArray(tmpV, normals, i * stride);
+            v3transformMat3(tmpV, v3normalize(tmpV, tmpV), n);
+            v3toArray(tmpV, normalArray, i * 3);
+        }
+
+        // color
+        for (let i = 0; i < vertexCount; ++i) {
+            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount * 2 + i });
+
+            let color: Color;
+            switch (colorType) {
+                case 'uniform':
+                    color = Color.fromNormalizedArray(values.uColor.ref.value, 0);
+                    break;
+                case 'instance':
+                    color = Color.fromArray(tColor, instanceIndex * 3);
+                    break;
+                case 'group': {
+                    const group = isGeoTexture ? GlbExporter.getGroup(groups, i) : groups[i];
+                    color = Color.fromArray(tColor, group * 3);
+                    break;
+                }
+                case 'groupInstance': {
+                    const group = isGeoTexture ? GlbExporter.getGroup(groups, i) : groups[i];
+                    color = Color.fromArray(tColor, (instanceIndex * groupCount + group) * 3);
+                    break;
+                }
+                case 'vertex':
+                    color = Color.fromArray(tColor, i * 3);
+                    break;
+                case 'vertexInstance':
+                    color = Color.fromArray(tColor, (instanceIndex * drawCount + i) * 3);
+                    break;
+                default: throw new Error('Unsupported color type.');
+            }
+
+            let alpha = uAlpha;
+            if (dTransparency) {
+                const group = isGeoTexture ? GlbExporter.getGroup(groups, i) : groups[i];
+                const transparency = tTransparency.array[instanceIndex * groupCount + group] / 255;
+                alpha *= 1 - transparency;
+            }
+
+            Color.toArrayNormalized(color, colorArray, i * 4);
+            colorArray[i * 4 + 3] = alpha;
+        }
+
+        // face
+        if (isGeoTexture) {
+            indexArray = new Uint32Array(drawCount);
+            fillSerial(indexArray);
+        } else {
+            indexArray = indices!.slice(0, drawCount);
+        }
+
+        const [ vertexMin, vertexMax ] = GlbExporter.vecMinMax(vertexArray, 3);
+        const [ normalMin, normalMax ] = GlbExporter.vecMinMax(normalArray, 3);
+        const [ colorMin, colorMax ] = GlbExporter.vecMinMax(colorArray, 4);
+        const [ indexMin, indexMax ] = arrayMinMax(indexArray);
+
+        // binary buffer
+        let vertexBuffer = vertexArray.buffer;
+        let normalBuffer = normalArray.buffer;
+        let colorBuffer = colorArray.buffer;
+        let indexBuffer = indexArray.buffer;
+        if (!IsNativeEndianLittle) {
+            vertexBuffer = flipByteOrder(new Uint8Array(vertexBuffer), 4);
+            normalBuffer = flipByteOrder(new Uint8Array(normalBuffer), 4);
+            colorBuffer = flipByteOrder(new Uint8Array(colorBuffer), 4);
+            indexBuffer = flipByteOrder(new Uint8Array(indexBuffer), 4);
+        }
+        this.binaryBuffer.push(vertexBuffer, normalBuffer, colorBuffer, indexBuffer);
+
+        // buffer views
+        const bufferViewOffset = this.bufferViews.length;
+
+        this.bufferViews.push({
+            buffer: 0,
+            byteOffset: this.byteOffset,
+            byteLength: vertexBuffer.byteLength,
+            target: 34962 // ARRAY_BUFFER
+        });
+        this.byteOffset += vertexBuffer.byteLength;
+
+        this.bufferViews.push({
+            buffer: 0,
+            byteOffset: this.byteOffset,
+            byteLength: normalBuffer.byteLength,
+            target: 34962 // ARRAY_BUFFER
+        });
+        this.byteOffset += normalBuffer.byteLength;
+
+        this.bufferViews.push({
+            buffer: 0,
+            byteOffset: this.byteOffset,
+            byteLength: colorBuffer.byteLength,
+            target: 34962 // ARRAY_BUFFER
+        });
+        this.byteOffset += colorBuffer.byteLength;
+
+        this.bufferViews.push({
+            buffer: 0,
+            byteOffset: this.byteOffset,
+            byteLength: indexBuffer.byteLength,
+            target: 34963 // ELEMENT_ARRAY_BUFFER
+        });
+        this.byteOffset += indexBuffer.byteLength;
+
+        // accessors
+        const accessorOffset = this.accessors.length;
+        this.accessors.push({
+            bufferView: bufferViewOffset,
+            byteOffset: 0,
+            componentType: 5126, // FLOAT
+            count: vertexCount,
+            type: 'VEC3',
+            max: vertexMax,
+            min: vertexMin
+        });
+        this.accessors.push({
+            bufferView: bufferViewOffset + 1,
+            byteOffset: 0,
+            componentType: 5126, // FLOAT
+            count: vertexCount,
+            type: 'VEC3',
+            max: normalMax,
+            min: normalMin
+        });
+        this.accessors.push({
+            bufferView: bufferViewOffset + 2,
+            byteOffset: 0,
+            componentType: 5126, // FLOAT
+            count: vertexCount,
+            type: 'VEC4',
+            max: colorMax,
+            min: colorMin
+        });
+        this.accessors.push({
+            bufferView: bufferViewOffset + 3,
+            byteOffset: 0,
+            componentType: 5125, // UNSIGNED_INT
+            count: drawCount,
+            type: 'SCALAR',
+            max: [ indexMax ],
+            min: [ indexMin ]
+        });
+
+        // primitive
+        this.primitives.push({
+            attributes: {
+                POSITION: accessorOffset,
+                NORMAL: accessorOffset + 1,
+                COLOR_0: accessorOffset + 2,
+            },
+            indices: accessorOffset + 3,
+            material: 0
+        });
+    }
+
+    getData() {
+        const binaryBufferLength = this.byteOffset;
+
+        const gltf = {
+            asset: {
+                version: '2.0'
+            },
+            scenes: [{
+                nodes: [ 0 ]
+            }],
+            nodes: [{
+                mesh: 0
+            }],
+            meshes: [{
+                primitives: this.primitives
+            }],
+            buffers: [{
+                byteLength: binaryBufferLength,
+            }],
+            bufferViews: this.bufferViews,
+            accessors: this.accessors,
+            materials: [{}]
+        };
+
+        const createChunk = (chunkType: number, data: ArrayBuffer[], byteLength: number, padChar: number): [ArrayBuffer[], number] => {
+            let padding = null;
+            if (byteLength % 4 !== 0) {
+                const pad = 4 - (byteLength % 4);
+                byteLength += pad;
+                padding = new Uint8Array(pad);
+                padding.fill(padChar);
+            }
+            const preamble = new ArrayBuffer(8);
+            const preambleDataView = new DataView(preamble);
+            preambleDataView.setUint32(0, byteLength, true);
+            preambleDataView.setUint32(4, chunkType, true);
+            const chunk = [preamble, ...data];
+            if (padding) {
+                chunk.push(padding.buffer);
+            }
+            return [ chunk, 8 + byteLength ];
+        };
+        const jsonString = JSON.stringify(gltf);
+        const jsonBuffer = new Uint8Array(jsonString.length);
+        asciiWrite(jsonBuffer, jsonString);
+
+        const [ jsonChunk, jsonChunkLength ] = createChunk(0x4E4F534A, [jsonBuffer.buffer], jsonBuffer.length, 0x20);
+        const [ binaryChunk, binaryChunkLength ] = createChunk(0x004E4942, this.binaryBuffer, binaryBufferLength, 0x00);
+
+        const glbBufferLength = 12 + jsonChunkLength + binaryChunkLength;
+        const header = new ArrayBuffer(12);
+        const headerDataView = new DataView(header);
+        headerDataView.setUint32(0, 0x46546C67, true); // magic number "glTF"
+        headerDataView.setUint32(4, 2, true); // version
+        headerDataView.setUint32(8, glbBufferLength, true); // length
+        const glbBuffer = [header, ...jsonChunk, ...binaryChunk];
+
+        const glb = new Uint8Array(glbBufferLength);
+        let offset = 0;
+        for (const buffer of glbBuffer) {
+            glb.set(new Uint8Array(buffer), offset);
+            offset += buffer.byteLength;
+        }
+        return { glb };
+    }
+
+    async getBlob(ctx: RuntimeContext) {
+        return new Blob([this.getData().glb], { type: 'model/gltf-binary' });
+    }
+}

+ 18 - 174
src/extensions/geo-export/export.ts → src/extensions/geo-export/mesh-exporter.ts

@@ -17,48 +17,23 @@ import { WebGLContext } from '../../mol-gl/webgl/context';
 import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
 import { addSphere } from '../../mol-geo/geometry/mesh/builder/sphere';
 import { addCylinder } from '../../mol-geo/geometry/mesh/builder/cylinder';
-import { Vec3, Mat3, Mat4 } from '../../mol-math/linear-algebra';
+import { sizeDataFactor } from '../../mol-geo/geometry/size-data';
+import { Vec3 } from '../../mol-math/linear-algebra';
 import { RuntimeContext } from '../../mol-task';
-import { StringBuilder } from '../../mol-util';
-import { Color } from '../../mol-util/color/color';
 import { decodeFloatRGB } from '../../mol-util/float-packing';
+import { RenderObjectExporter, RenderObjectExportData } from './render-object-exporter';
 
 // avoiding namespace lookup improved performance in Chrome (Aug 2020)
 const v3fromArray = Vec3.fromArray;
-const v3transformMat4 = Vec3.transformMat4;
-const v3transformMat3 = Vec3.transformMat3;
-const mat3directionTransform = Mat3.directionTransform;
-
-type RenderObjectExportData = {
-    [k: string]: string | Uint8Array | undefined
-}
-
-interface RenderObjectExporter<D extends RenderObjectExportData> {
-    add(renderObject: GraphicsRenderObject, webgl: WebGLContext, ctx: RuntimeContext): Promise<void> | undefined
-    getData(): D
-}
-
-// http://paulbourke.net/dataformats/obj/
-// http://paulbourke.net/dataformats/mtl/
-
-export type ObjData = {
-    obj: string
-    mtl: string
-}
-
-export class ObjExporter implements RenderObjectExporter<ObjData> {
-    private obj = StringBuilder.create();
-    private mtl = StringBuilder.create();
-    private vertexOffset = 0;
-    private currentColor: Color | undefined;
-    private currentAlpha: number | undefined;
-    private materialSet = new Set<string>();
+
+export abstract class MeshExporter<D extends RenderObjectExportData> implements RenderObjectExporter<D> {
+    abstract readonly fileExtension: string;
 
     private static getSizeFromTexture(tSize: TextureImage<Uint8Array>, i: number): number {
         const r = tSize.array[i * 3];
         const g = tSize.array[i * 3 + 1];
         const b = tSize.array[i * 3 + 2];
-        return decodeFloatRGB(r, g, b);
+        return decodeFloatRGB(r, g, b) / sizeDataFactor;
     }
 
     private static getSize(values: BaseValues & SizeValues, instanceIndex: number, group: number): number {
@@ -69,20 +44,20 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
                 size = values.uSize.ref.value;
                 break;
             case 'instance':
-                size = ObjExporter.getSizeFromTexture(tSize, instanceIndex) / 100;
+                size = MeshExporter.getSizeFromTexture(tSize, instanceIndex);
                 break;
             case 'group':
-                size = ObjExporter.getSizeFromTexture(tSize, group) / 100;
+                size = MeshExporter.getSizeFromTexture(tSize, group);
                 break;
             case 'groupInstance':
                 const groupCount = values.uGroupCount.ref.value;
-                size = ObjExporter.getSizeFromTexture(tSize, instanceIndex * groupCount + group) / 100;
+                size = MeshExporter.getSizeFromTexture(tSize, instanceIndex * groupCount + group);
                 break;
         }
         return size * values.uSizeFactor.ref.value;
     }
 
-    private static getGroup(groups: Float32Array | Uint8Array, i: number): number {
+    protected static getGroup(groups: Float32Array | Uint8Array, i: number): number {
         const i4 = i * 4;
         const r = groups[i4];
         const g = groups[i4 + 1];
@@ -93,131 +68,7 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
         return decodeFloatRGB(r, g, b);
     }
 
-    private updateMaterial(color: Color, alpha: number) {
-        if (this.currentColor === color && this.currentAlpha === alpha) return;
-
-        this.currentColor = color;
-        this.currentAlpha = alpha;
-        const material = Color.toHexString(color) + alpha;
-        StringBuilder.writeSafe(this.obj, `usemtl ${material}`);
-        StringBuilder.newline(this.obj);
-        if (!this.materialSet.has(material)) {
-            this.materialSet.add(material);
-            const [r, g, b] = Color.toRgbNormalized(color);
-            const mtl = this.mtl;
-            StringBuilder.writeSafe(mtl, `newmtl ${material}\n`);
-            StringBuilder.writeSafe(mtl, 'illum 2\n'); // illumination model
-            StringBuilder.writeSafe(mtl, 'Ns 163\n'); // specular exponent
-            StringBuilder.writeSafe(mtl, 'Ni 0.001\n'); // optical density a.k.a. index of refraction
-            StringBuilder.writeSafe(mtl, 'Ka 0 0 0\n'); // ambient reflectivity
-            StringBuilder.writeSafe(mtl, 'Kd '); // diffuse reflectivity
-            StringBuilder.writeFloat(mtl, r, 1000);
-            StringBuilder.whitespace1(mtl);
-            StringBuilder.writeFloat(mtl, g, 1000);
-            StringBuilder.whitespace1(mtl);
-            StringBuilder.writeFloat(mtl, b, 1000);
-            StringBuilder.newline(mtl);
-            StringBuilder.writeSafe(mtl, 'Ks 0.25 0.25 0.25\n'); // specular reflectivity
-            StringBuilder.writeSafe(mtl, 'd '); // dissolve
-            StringBuilder.writeFloat(mtl, alpha, 1000);
-            StringBuilder.newline(mtl);
-        }
-    }
-
-    private async addMeshWithColors(vertices: Float32Array, normals: Float32Array, indices: Uint32Array | undefined, groups: Float32Array | Uint8Array, vertexCount: number, drawCount: number, values: BaseValues, instanceIndex: number, geoTexture: boolean, ctx: RuntimeContext) {
-        const obj = this.obj;
-        const t = Mat4();
-        const n = Mat3();
-        const tmpV = Vec3();
-        const stride = geoTexture ? 4 : 3;
-
-        const colorType = values.dColorType.ref.value;
-        const tColor = values.tColor.ref.value.array;
-        const uAlpha = values.uAlpha.ref.value;
-        const aTransform = values.aTransform.ref.value;
-
-        Mat4.fromArray(t, aTransform, instanceIndex * 16);
-        mat3directionTransform(n, t);
-
-        const currentProgress = (vertexCount * 2 + drawCount) * instanceIndex;
-        await ctx.update({ isIndeterminate: false, current: currentProgress, max: (vertexCount * 2 + drawCount) * values.uInstanceCount.ref.value });
-
-        // position
-        for (let i = 0; i < vertexCount; ++i) {
-            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + i });
-            v3transformMat4(tmpV, v3fromArray(tmpV, vertices, i * stride), t);
-            StringBuilder.writeSafe(obj, 'v ');
-            StringBuilder.writeFloat(obj, tmpV[0], 1000);
-            StringBuilder.whitespace1(obj);
-            StringBuilder.writeFloat(obj, tmpV[1], 1000);
-            StringBuilder.whitespace1(obj);
-            StringBuilder.writeFloat(obj, tmpV[2], 1000);
-            StringBuilder.newline(obj);
-        }
-
-        // normal
-        for (let i = 0; i < vertexCount; ++i) {
-            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount + i });
-            v3transformMat3(tmpV, v3fromArray(tmpV, normals, i * stride), n);
-            StringBuilder.writeSafe(obj, 'vn ');
-            StringBuilder.writeFloat(obj, tmpV[0], 100);
-            StringBuilder.whitespace1(obj);
-            StringBuilder.writeFloat(obj, tmpV[1], 100);
-            StringBuilder.whitespace1(obj);
-            StringBuilder.writeFloat(obj, tmpV[2], 100);
-            StringBuilder.newline(obj);
-        }
-
-        // face
-        for (let i = 0; i < drawCount; i += 3) {
-            if (i % 3000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount * 2 + i });
-            let color: Color;
-            switch (colorType) {
-                case 'uniform':
-                    color = Color.fromNormalizedArray(values.uColor.ref.value, 0);
-                    break;
-                case 'instance':
-                    color = Color.fromArray(tColor, instanceIndex * 3);
-                    break;
-                case 'group': {
-                    const group = geoTexture ? ObjExporter.getGroup(groups, i) : groups[indices![i]];
-                    color = Color.fromArray(tColor, group * 3);
-                    break;
-                }
-                case 'groupInstance': {
-                    const groupCount = values.uGroupCount.ref.value;
-                    const group = geoTexture ? ObjExporter.getGroup(groups, i) : groups[indices![i]];
-                    color = Color.fromArray(tColor, (instanceIndex * groupCount + group) * 3);
-                    break;
-                }
-                case 'vertex':
-                    color = Color.fromArray(tColor, i * 3);
-                    break;
-                case 'vertexInstance':
-                    color = Color.fromArray(tColor, (instanceIndex * drawCount + i) * 3);
-                    break;
-                default: throw new Error('Unsupported color type.');
-            }
-            this.updateMaterial(color, uAlpha);
-
-            const v1 = this.vertexOffset + (geoTexture ? i : indices![i]) + 1;
-            const v2 = this.vertexOffset + (geoTexture ? i + 1 : indices![i + 1]) + 1;
-            const v3 = this.vertexOffset + (geoTexture ? i + 2 : indices![i + 2]) + 1;
-            StringBuilder.writeSafe(obj, 'f ');
-            StringBuilder.writeInteger(obj, v1);
-            StringBuilder.writeSafe(obj, '//');
-            StringBuilder.writeIntegerAndSpace(obj, v1);
-            StringBuilder.writeInteger(obj, v2);
-            StringBuilder.writeSafe(obj, '//');
-            StringBuilder.writeIntegerAndSpace(obj, v2);
-            StringBuilder.writeInteger(obj, v3);
-            StringBuilder.writeSafe(obj, '//');
-            StringBuilder.writeInteger(obj, v3);
-            StringBuilder.newline(obj);
-        }
-
-        this.vertexOffset += vertexCount;
-    }
+    protected abstract addMeshWithColors(vertices: Float32Array, normals: Float32Array, indices: Uint32Array | undefined, groups: Float32Array | Uint8Array, vertexCount: number, drawCount: number, values: BaseValues, instanceIndex: number, isGeoTexture: boolean, ctx: RuntimeContext): void;
 
     private async addMesh(values: MeshValues, ctx: RuntimeContext) {
         const aPosition = values.aPosition.ref.value;
@@ -256,7 +107,7 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
                 v3fromArray(center, aPosition, i * 3);
 
                 const group = aGroup[i];
-                const radius = ObjExporter.getSize(values, instanceIndex, group);
+                const radius = MeshExporter.getSize(values, instanceIndex, group);
                 state.currentGroup = group;
                 addSphere(state, center, radius, 2);
             }
@@ -266,7 +117,7 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
             const normals = mesh.normalBuffer.ref.value;
             const indices = mesh.indexBuffer.ref.value;
             const groups = mesh.groupBuffer.ref.value;
-            await this.addMeshWithColors(vertices, normals, indices, groups, vertices.length / 3, indices.length, values, instanceIndex, false, ctx);
+            await this.addMeshWithColors(vertices, normals, indices, groups, mesh.vertexCount, indices.length, values, instanceIndex, false, ctx);
         }
     }
 
@@ -290,7 +141,7 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
                 v3fromArray(end, aEnd, i * 3);
 
                 const group = aGroup[i];
-                const radius = ObjExporter.getSize(values, instanceIndex, group) * aScale[i];
+                const radius = MeshExporter.getSize(values, instanceIndex, group) * aScale[i];
                 const cap = aCap[i];
                 const topCap = cap === 1 || cap === 3;
                 const bottomCap = cap >= 2;
@@ -304,7 +155,7 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
             const normals = mesh.normalBuffer.ref.value;
             const indices = mesh.indexBuffer.ref.value;
             const groups = mesh.groupBuffer.ref.value;
-            await this.addMeshWithColors(vertices, normals, indices, groups, vertices.length / 3, indices.length, values, instanceIndex, false, ctx);
+            await this.addMeshWithColors(vertices, normals, indices, groups, mesh.vertexCount, indices.length, values, instanceIndex, false, ctx);
         }
     }
 
@@ -356,14 +207,7 @@ export class ObjExporter implements RenderObjectExporter<ObjData> {
         }
     }
 
-    getData() {
-        return {
-            obj: StringBuilder.getString(this.obj),
-            mtl: StringBuilder.getString(this.mtl)
-        };
-    }
+    abstract getData(): D;
 
-    constructor(filename: string) {
-        StringBuilder.writeSafe(this.obj, `mtllib ${filename}.mtl\n`);
-    }
+    abstract getBlob(ctx: RuntimeContext): Promise<Blob>;
 }

+ 199 - 0
src/extensions/geo-export/obj-exporter.ts

@@ -0,0 +1,199 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
+ */
+
+import { BaseValues } from '../../mol-gl/renderable/schema';
+import { asciiWrite } from '../../mol-io/common/ascii';
+import { Vec3, Mat3, Mat4 } from '../../mol-math/linear-algebra';
+import { RuntimeContext } from '../../mol-task';
+import { StringBuilder } from '../../mol-util';
+import { Color } from '../../mol-util/color/color';
+import { zip } from '../../mol-util/zip/zip';
+import { MeshExporter } from './mesh-exporter';
+
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3fromArray = Vec3.fromArray;
+const v3transformMat4 = Vec3.transformMat4;
+const v3transformMat3 = Vec3.transformMat3;
+const mat3directionTransform = Mat3.directionTransform;
+
+// http://paulbourke.net/dataformats/obj/
+// http://paulbourke.net/dataformats/mtl/
+
+export type ObjData = {
+    obj: string
+    mtl: string
+}
+
+export class ObjExporter extends MeshExporter<ObjData> {
+    readonly fileExtension = 'zip';
+    private obj = StringBuilder.create();
+    private mtl = StringBuilder.create();
+    private vertexOffset = 0;
+    private currentColor: Color | undefined;
+    private currentAlpha: number | undefined;
+    private materialSet = new Set<string>();
+
+    private updateMaterial(color: Color, alpha: number) {
+        if (this.currentColor === color && this.currentAlpha === alpha) return;
+
+        this.currentColor = color;
+        this.currentAlpha = alpha;
+        const material = Color.toHexString(color) + alpha;
+        StringBuilder.writeSafe(this.obj, `usemtl ${material}`);
+        StringBuilder.newline(this.obj);
+        if (!this.materialSet.has(material)) {
+            this.materialSet.add(material);
+            const [r, g, b] = Color.toRgbNormalized(color);
+            const mtl = this.mtl;
+            StringBuilder.writeSafe(mtl, `newmtl ${material}\n`);
+            StringBuilder.writeSafe(mtl, 'illum 2\n'); // illumination model
+            StringBuilder.writeSafe(mtl, 'Ns 163\n'); // specular exponent
+            StringBuilder.writeSafe(mtl, 'Ni 0.001\n'); // optical density a.k.a. index of refraction
+            StringBuilder.writeSafe(mtl, 'Ka 0 0 0\n'); // ambient reflectivity
+            StringBuilder.writeSafe(mtl, 'Kd '); // diffuse reflectivity
+            StringBuilder.writeFloat(mtl, r, 1000);
+            StringBuilder.whitespace1(mtl);
+            StringBuilder.writeFloat(mtl, g, 1000);
+            StringBuilder.whitespace1(mtl);
+            StringBuilder.writeFloat(mtl, b, 1000);
+            StringBuilder.newline(mtl);
+            StringBuilder.writeSafe(mtl, 'Ks 0.25 0.25 0.25\n'); // specular reflectivity
+            StringBuilder.writeSafe(mtl, 'd '); // dissolve
+            StringBuilder.writeFloat(mtl, alpha, 1000);
+            StringBuilder.newline(mtl);
+        }
+    }
+
+    protected async addMeshWithColors(vertices: Float32Array, normals: Float32Array, indices: Uint32Array | undefined, groups: Float32Array | Uint8Array, vertexCount: number, drawCount: number, values: BaseValues, instanceIndex: number, isGeoTexture: boolean, ctx: RuntimeContext) {
+        const obj = this.obj;
+        const t = Mat4();
+        const n = Mat3();
+        const tmpV = Vec3();
+        const stride = isGeoTexture ? 4 : 3;
+
+        const groupCount = values.uGroupCount.ref.value;
+        const colorType = values.dColorType.ref.value;
+        const tColor = values.tColor.ref.value.array;
+        const uAlpha = values.uAlpha.ref.value;
+        const dTransparency = values.dTransparency.ref.value;
+        const tTransparency = values.tTransparency.ref.value;
+        const aTransform = values.aTransform.ref.value;
+
+        Mat4.fromArray(t, aTransform, instanceIndex * 16);
+        mat3directionTransform(n, t);
+
+        const currentProgress = (vertexCount * 2 + drawCount) * instanceIndex;
+        await ctx.update({ isIndeterminate: false, current: currentProgress, max: (vertexCount * 2 + drawCount) * values.uInstanceCount.ref.value });
+
+        // position
+        for (let i = 0; i < vertexCount; ++i) {
+            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + i });
+            v3transformMat4(tmpV, v3fromArray(tmpV, vertices, i * stride), t);
+            StringBuilder.writeSafe(obj, 'v ');
+            StringBuilder.writeFloat(obj, tmpV[0], 1000);
+            StringBuilder.whitespace1(obj);
+            StringBuilder.writeFloat(obj, tmpV[1], 1000);
+            StringBuilder.whitespace1(obj);
+            StringBuilder.writeFloat(obj, tmpV[2], 1000);
+            StringBuilder.newline(obj);
+        }
+
+        // normal
+        for (let i = 0; i < vertexCount; ++i) {
+            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount + i });
+            v3transformMat3(tmpV, v3fromArray(tmpV, normals, i * stride), n);
+            StringBuilder.writeSafe(obj, 'vn ');
+            StringBuilder.writeFloat(obj, tmpV[0], 100);
+            StringBuilder.whitespace1(obj);
+            StringBuilder.writeFloat(obj, tmpV[1], 100);
+            StringBuilder.whitespace1(obj);
+            StringBuilder.writeFloat(obj, tmpV[2], 100);
+            StringBuilder.newline(obj);
+        }
+
+        // face
+        for (let i = 0; i < drawCount; i += 3) {
+            if (i % 3000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount * 2 + i });
+            let color: Color;
+            switch (colorType) {
+                case 'uniform':
+                    color = Color.fromNormalizedArray(values.uColor.ref.value, 0);
+                    break;
+                case 'instance':
+                    color = Color.fromArray(tColor, instanceIndex * 3);
+                    break;
+                case 'group': {
+                    const group = isGeoTexture ? ObjExporter.getGroup(groups, i) : groups[indices![i]];
+                    color = Color.fromArray(tColor, group * 3);
+                    break;
+                }
+                case 'groupInstance': {
+                    const group = isGeoTexture ? ObjExporter.getGroup(groups, i) : groups[indices![i]];
+                    color = Color.fromArray(tColor, (instanceIndex * groupCount + group) * 3);
+                    break;
+                }
+                case 'vertex':
+                    color = Color.fromArray(tColor, indices![i] * 3);
+                    break;
+                case 'vertexInstance':
+                    color = Color.fromArray(tColor, (instanceIndex * drawCount + indices![i]) * 3);
+                    break;
+                default: throw new Error('Unsupported color type.');
+            }
+
+            let alpha = uAlpha;
+            if (dTransparency) {
+                const group = isGeoTexture ? ObjExporter.getGroup(groups, i) : groups[indices![i]];
+                const transparency = tTransparency.array[instanceIndex * groupCount + group] / 255;
+                alpha *= 1 - transparency;
+            }
+
+            this.updateMaterial(color, alpha);
+
+            const v1 = this.vertexOffset + (isGeoTexture ? i : indices![i]) + 1;
+            const v2 = this.vertexOffset + (isGeoTexture ? i + 1 : indices![i + 1]) + 1;
+            const v3 = this.vertexOffset + (isGeoTexture ? i + 2 : indices![i + 2]) + 1;
+            StringBuilder.writeSafe(obj, 'f ');
+            StringBuilder.writeInteger(obj, v1);
+            StringBuilder.writeSafe(obj, '//');
+            StringBuilder.writeIntegerAndSpace(obj, v1);
+            StringBuilder.writeInteger(obj, v2);
+            StringBuilder.writeSafe(obj, '//');
+            StringBuilder.writeIntegerAndSpace(obj, v2);
+            StringBuilder.writeInteger(obj, v3);
+            StringBuilder.writeSafe(obj, '//');
+            StringBuilder.writeInteger(obj, v3);
+            StringBuilder.newline(obj);
+        }
+
+        this.vertexOffset += vertexCount;
+    }
+
+    getData() {
+        return {
+            obj: StringBuilder.getString(this.obj),
+            mtl: StringBuilder.getString(this.mtl)
+        };
+    }
+
+    async getBlob(ctx: RuntimeContext) {
+        const { obj, mtl } = this.getData();
+        const objData = new Uint8Array(obj.length);
+        asciiWrite(objData, obj);
+        const mtlData = new Uint8Array(mtl.length);
+        asciiWrite(mtlData, mtl);
+        const zipDataObj = {
+            [this.filename + '.obj']: objData,
+            [this.filename + '.mtl']: mtlData
+        };
+        return new Blob([await zip(ctx, zipDataObj)], { type: 'application/zip' });
+    }
+
+    constructor(private filename: string) {
+        super();
+        StringBuilder.writeSafe(this.obj, `mtllib ${filename}.mtl\n`);
+    }
+}

+ 20 - 0
src/extensions/geo-export/render-object-exporter.ts

@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
+ */
+
+import { GraphicsRenderObject } from '../../mol-gl/render-object';
+import { WebGLContext } from '../../mol-gl/webgl/context';
+import { RuntimeContext } from '../../mol-task';
+
+export type RenderObjectExportData = {
+    [k: string]: string | Uint8Array | undefined
+}
+
+export interface RenderObjectExporter<D extends RenderObjectExportData> {
+    readonly fileExtension: string
+    add(renderObject: GraphicsRenderObject, webgl: WebGLContext, ctx: RuntimeContext): Promise<void> | undefined
+    getData(): D
+    getBlob(ctx: RuntimeContext): Promise<Blob>
+}

+ 105 - 0
src/extensions/geo-export/stl-exporter.ts

@@ -0,0 +1,105 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
+ */
+
+import { BaseValues } from '../../mol-gl/renderable/schema';
+import { asciiWrite } from '../../mol-io/common/ascii';
+import { Vec3, Mat4 } from '../../mol-math/linear-algebra';
+import { PLUGIN_VERSION } from '../../mol-plugin/version';
+import { RuntimeContext } from '../../mol-task';
+import { MeshExporter } from './mesh-exporter';
+
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3fromArray = Vec3.fromArray;
+const v3transformMat4 = Vec3.transformMat4;
+const v3triangleNormal = Vec3.triangleNormal;
+const v3toArray = Vec3.toArray;
+
+// https://www.fabbers.com/tech/STL_Format
+
+export type StlData = {
+    stl: Uint8Array
+}
+
+export class StlExporter extends MeshExporter<StlData> {
+    readonly fileExtension = 'stl';
+    private triangleBuffers: ArrayBuffer[] = [];
+    private triangleCount = 0;
+
+    protected async addMeshWithColors(vertices: Float32Array, normals: Float32Array, indices: Uint32Array | undefined, groups: Float32Array | Uint8Array, vertexCount: number, drawCount: number, values: BaseValues, instanceIndex: number, isGeoTexture: boolean, ctx: RuntimeContext) {
+        const t = Mat4();
+        const tmpV = Vec3();
+        const v1 = Vec3();
+        const v2 = Vec3();
+        const v3 = Vec3();
+        const stride = isGeoTexture ? 4 : 3;
+
+        const aTransform = values.aTransform.ref.value;
+        Mat4.fromArray(t, aTransform, instanceIndex * 16);
+
+        const currentProgress = (vertexCount + drawCount) * instanceIndex;
+        await ctx.update({ isIndeterminate: false, current: currentProgress, max: (vertexCount + drawCount) * values.uInstanceCount.ref.value });
+
+        // position
+        const vertexArray = new Float32Array(vertexCount * 3);
+        for (let i = 0; i < vertexCount; ++i) {
+            if (i % 1000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + i });
+            v3transformMat4(tmpV, v3fromArray(tmpV, vertices, i * stride), t);
+            v3toArray(tmpV, vertexArray, i * 3);
+        }
+
+        // face
+        const triangleBuffer = new ArrayBuffer(50 * drawCount);
+        const dataView = new DataView(triangleBuffer);
+        for (let i = 0; i < drawCount; i += 3) {
+            if (i % 3000 === 0 && ctx.shouldUpdate) await ctx.update({ current: currentProgress + vertexCount + i });
+
+            v3fromArray(v1, vertexArray, (isGeoTexture ? i : indices![i]) * 3);
+            v3fromArray(v2, vertexArray, (isGeoTexture ? i + 1 : indices![i + 1]) * 3);
+            v3fromArray(v3, vertexArray, (isGeoTexture ? i + 2 : indices![i + 2]) * 3);
+            v3triangleNormal(tmpV, v1, v2, v3);
+
+            const byteOffset = 50 * i;
+            dataView.setFloat32(byteOffset, tmpV[0], true);
+            dataView.setFloat32(byteOffset + 4, tmpV[1], true);
+            dataView.setFloat32(byteOffset + 8, tmpV[2], true);
+
+            dataView.setFloat32(byteOffset + 12, v1[0], true);
+            dataView.setFloat32(byteOffset + 16, v1[1], true);
+            dataView.setFloat32(byteOffset + 20, v1[2], true);
+
+            dataView.setFloat32(byteOffset + 24, v2[0], true);
+            dataView.setFloat32(byteOffset + 28, v2[1], true);
+            dataView.setFloat32(byteOffset + 32, v2[2], true);
+
+            dataView.setFloat32(byteOffset + 36, v3[0], true);
+            dataView.setFloat32(byteOffset + 40, v3[1], true);
+            dataView.setFloat32(byteOffset + 44, v3[2], true);
+        }
+
+        this.triangleBuffers.push(triangleBuffer);
+        this.triangleCount += drawCount;
+    }
+
+    getData() {
+        const stl = new Uint8Array(84 + 50 * this.triangleCount);
+
+        asciiWrite(stl, `Exported from Mol* ${PLUGIN_VERSION}`);
+
+        const dataView = new DataView(stl.buffer);
+        dataView.setUint32(80, this.triangleCount, true);
+
+        let byteOffset = 84;
+        for (const buffer of this.triangleBuffers) {
+            stl.set(new Uint8Array(buffer), byteOffset);
+            byteOffset += buffer.byteLength;
+        }
+        return { stl };
+    }
+
+    async getBlob(ctx: RuntimeContext) {
+        return new Blob([this.getData().stl], { type: 'model/stl' });
+    }
+}

+ 22 - 8
src/extensions/geo-export/ui.tsx

@@ -4,11 +4,13 @@
  * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
  */
 
+import { merge } from 'rxjs';
 import { CollapsableControls, CollapsableState } from '../../mol-plugin-ui/base';
 import { Button } from '../../mol-plugin-ui/controls/common';
 import { GetAppSvg, CubeSendSvg } from '../../mol-plugin-ui/controls/icons';
+import { ParameterControls } from '../../mol-plugin-ui/controls/parameters';
 import { download } from '../../mol-util/download';
-import { GeometryControls } from './controls';
+import { GeometryParams, GeometryControls } from './controls';
 
 interface State {
     busy?: boolean
@@ -23,24 +25,36 @@ export class GeometryExporterUI extends CollapsableControls<{}, State> {
 
     protected defaultState(): State & CollapsableState {
         return {
-            header: 'Export Geometries',
+            header: 'Export Geometry',
             isCollapsed: true,
             brand: { accent: 'cyan', svg: CubeSendSvg }
         };
     }
 
     protected renderControls(): JSX.Element {
+        const ctrl = this.controls;
         return <>
+            <ParameterControls
+                params={GeometryParams}
+                values={ctrl.behaviors.params.value}
+                onChangeValues={xs => ctrl.behaviors.params.next(xs)}
+                isDisabled={this.state.busy}
+            />
             <Button icon={GetAppSvg}
-                onClick={this.saveObj} style={{ marginTop: 1 }}
+                onClick={this.save} style={{ marginTop: 1 }}
                 disabled={this.state.busy || !this.plugin.canvas3d?.reprCount.value}>
-                Save OBJ + MTL
+                Save
             </Button>
         </>;
     }
 
     componentDidMount() {
-        this.subscribe(this.plugin.canvas3d!.reprCount, () => {
+        const merged = merge(
+            this.controls.behaviors.params,
+            this.plugin.canvas3d!.reprCount
+        );
+
+        this.subscribe(merged, () => {
             if (!this.state.isCollapsed) this.forceUpdate();
         });
     }
@@ -50,13 +64,13 @@ export class GeometryExporterUI extends CollapsableControls<{}, State> {
         this._controls = void 0;
     }
 
-    saveObj = async () => {
+    save = async () => {
         try {
             this.setState({ busy: true });
-            const data = await this.controls.exportObj();
+            const data = await this.controls.exportGeometry();
             this.setState({ busy: false });
 
-            download(new Blob([data.zipData]), data.filename);
+            download(data.blob, data.filename);
         } catch {
             this.setState({ busy: false });
         }

+ 14 - 7
src/mol-geo/geometry/mesh/builder/cylinder.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -25,12 +25,12 @@ const tmpCylinderMatScale = Mat4();
 const tmpCylinderStart = Vec3();
 const tmpUp = Vec3();
 
-function setCylinderMat(m: Mat4, start: Vec3, dir: Vec3, length: number) {
+function setCylinderMat(m: Mat4, start: Vec3, dir: Vec3, length: number, matchDir: boolean) {
     Vec3.setMagnitude(tmpCylinderMatDir, dir, length / 2);
     Vec3.add(tmpCylinderCenter, start, tmpCylinderMatDir);
     // ensure the direction used to create the rotation is always pointing in the same
     // direction so the triangles of adjacent cylinder will line up
-    Vec3.matchDirection(tmpUp, up, tmpCylinderMatDir);
+    if (matchDir) Vec3.matchDirection(tmpUp, up, tmpCylinderMatDir);
     Mat4.fromScaling(tmpCylinderMatScale, Vec3.set(tmpCylinderScale, 1, length, 1));
     Vec3.makeRotation(tmpCylinderMatRot, tmpUp, tmpCylinderMatDir);
     Mat4.mul(m, tmpCylinderMatRot, tmpCylinderMatScale);
@@ -69,10 +69,17 @@ function getCylinder(props: CylinderProps) {
 
 export type BasicCylinderProps = Omit<CylinderProps, 'height'>
 
+export function addSimpleCylinder(state: MeshBuilder.State, start: Vec3, end: Vec3, props: BasicCylinderProps) {
+    const d = Vec3.distance(start, end);
+    Vec3.sub(tmpCylinderDir, end, start);
+    setCylinderMat(tmpCylinderMat, start, tmpCylinderDir, d, false);
+    MeshBuilder.addPrimitive(state, tmpCylinderMat, getCylinder(props));
+}
+
 export function addCylinder(state: MeshBuilder.State, start: Vec3, end: Vec3, lengthScale: number, props: BasicCylinderProps) {
     const d = Vec3.distance(start, end) * lengthScale;
     Vec3.sub(tmpCylinderDir, end, start);
-    setCylinderMat(tmpCylinderMat, start, tmpCylinderDir, d);
+    setCylinderMat(tmpCylinderMat, start, tmpCylinderDir, d, true);
     MeshBuilder.addPrimitive(state, tmpCylinderMat, getCylinder(props));
 }
 
@@ -82,11 +89,11 @@ export function addDoubleCylinder(state: MeshBuilder.State, start: Vec3, end: Ve
     Vec3.sub(tmpCylinderDir, end, start);
     // positivly shifted cylinder
     Vec3.add(tmpCylinderStart, start, shift);
-    setCylinderMat(tmpCylinderMat, tmpCylinderStart, tmpCylinderDir, d);
+    setCylinderMat(tmpCylinderMat, tmpCylinderStart, tmpCylinderDir, d, true);
     MeshBuilder.addPrimitive(state, tmpCylinderMat, cylinder);
     // negativly shifted cylinder
     Vec3.sub(tmpCylinderStart, start, shift);
-    setCylinderMat(tmpCylinderMat, tmpCylinderStart, tmpCylinderDir, d);
+    setCylinderMat(tmpCylinderMat, tmpCylinderStart, tmpCylinderDir, d, true);
     MeshBuilder.addPrimitive(state, tmpCylinderMat, cylinder);
 }
 
@@ -109,7 +116,7 @@ export function addFixedCountDashedCylinder(state: MeshBuilder.State, start: Vec
         const f = step * (j * 2 + 1);
         Vec3.setMagnitude(tmpCylinderDir, tmpCylinderDir, d * f);
         Vec3.add(tmpCylinderStart, start, tmpCylinderDir);
-        setCylinderMat(tmpCylinderMat, tmpCylinderStart, tmpCylinderDir, d * step);
+        setCylinderMat(tmpCylinderMat, tmpCylinderStart, tmpCylinderDir, d * step, false);
         MeshBuilder.addPrimitive(state, tmpCylinderMat, cylinder);
     }
 }

+ 13 - 12
src/mol-geo/geometry/mesh/builder/sheet.ts

@@ -36,6 +36,7 @@ const v3magnitude = Vec3.magnitude;
 const v3negate = Vec3.negate;
 const v3copy = Vec3.copy;
 const v3cross = Vec3.cross;
+const v3set = Vec3.set;
 const caAdd3 = ChunkedArray.add3;
 const caAdd = ChunkedArray.add;
 
@@ -43,14 +44,14 @@ function addCap(offset: number, state: MeshBuilder.State, controlPoints: ArrayLi
     const { vertices, normals, indices } = state;
     const vertexCount = vertices.elementCount;
 
-    v3fromArray(verticalLeftVector, normalVectors, offset);
-    v3scale(verticalLeftVector, verticalLeftVector, leftHeight);
+    v3fromArray(tA, normalVectors, offset);
+    v3scale(verticalLeftVector, tA, leftHeight);
+    v3scale(verticalRightVector, tA, rightHeight);
 
-    v3fromArray(verticalRightVector, normalVectors, offset);
-    v3scale(verticalRightVector, verticalRightVector, rightHeight);
+    v3fromArray(tB, binormalVectors, offset);
+    v3scale(horizontalVector, tB, width);
 
-    v3fromArray(horizontalVector, binormalVectors, offset);
-    v3scale(horizontalVector, horizontalVector, width);
+    v3cross(normalVector, tB, tA);
 
     v3fromArray(positionVector, controlPoints, offset);
 
@@ -73,8 +74,6 @@ function addCap(offset: number, state: MeshBuilder.State, controlPoints: ArrayLi
         v3copy(verticalVector, verticalLeftVector);
     }
 
-    v3cross(normalVector, horizontalVector, verticalVector);
-
     for (let i = 0; i < 4; ++i) {
         caAdd3(normals, normalVector[0], normalVector[1], normalVector[2]);
     }
@@ -93,6 +92,8 @@ export function addSheet(state: MeshBuilder.State, controlPoints: ArrayLike<numb
         v3fromArray(tA, controlPoints, 0);
         v3fromArray(tB, controlPoints, linearSegments * 3);
         offsetLength = arrowHeight / v3magnitude(v3sub(tV, tB, tA));
+    } else {
+        v3set(normalOffset, 0, 0, 0);
     }
 
     for (let i = 0; i <= linearSegments; ++i) {
@@ -119,7 +120,7 @@ export function addSheet(state: MeshBuilder.State, controlPoints: ArrayLike<numb
         v3fromArray(torsionVector, binormalVectors, i3);
 
         v3add(tA, v3add(tA, positionVector, horizontalVector), verticalVector);
-        v3copy(tB, normalVector);
+        v3add(tB, normalVector, normalOffset);
         caAdd3(vertices, tA[0], tA[1], tA[2]);
         caAdd3(normals, tB[0], tB[1], tB[2]);
 
@@ -128,7 +129,7 @@ export function addSheet(state: MeshBuilder.State, controlPoints: ArrayLike<numb
         caAdd3(normals, tB[0], tB[1], tB[2]);
 
         // v3add(tA, v3sub(tA, positionVector, horizontalVector), verticalVector) // reuse tA
-        v3add(tB, v3negate(tB, torsionVector), normalOffset);
+        v3negate(tB, torsionVector);
         caAdd3(vertices, tA[0], tA[1], tA[2]);
         caAdd3(normals, tB[0], tB[1], tB[2]);
 
@@ -137,7 +138,7 @@ export function addSheet(state: MeshBuilder.State, controlPoints: ArrayLike<numb
         caAdd3(normals, tB[0], tB[1], tB[2]);
 
         // v3sub(tA, v3sub(tA, positionVector, horizontalVector), verticalVector) // reuse tA
-        v3negate(tB, normalVector);
+        v3add(tB, v3negate(tB, normalVector), normalOffset);
         caAdd3(vertices, tA[0], tA[1], tA[2]);
         caAdd3(normals, tB[0], tB[1], tB[2]);
 
@@ -146,7 +147,7 @@ export function addSheet(state: MeshBuilder.State, controlPoints: ArrayLike<numb
         caAdd3(normals, tB[0], tB[1], tB[2]);
 
         // v3sub(tA, v3add(tA, positionVector, horizontalVector), verticalVector) // reuse tA
-        v3add(tB, torsionVector, normalOffset);
+        v3copy(tB, torsionVector);
         caAdd3(vertices, tA[0], tA[1], tA[2]);
         caAdd3(normals, tB[0], tB[1], tB[2]);
 

+ 5 - 5
src/mol-geo/geometry/size-data.ts

@@ -31,7 +31,7 @@ export function createSizes(locationIt: LocationIterator, sizeTheme: SizeTheme<a
     }
 }
 
-const sizeFactor = 100; // NOTE same factor is set in shaders
+export const sizeDataFactor = 100; // NOTE same factor is set in shaders
 
 export function getMaxSize(sizeData: SizeData): number {
     const type = sizeData.dSizeType.ref.value as SizeType;
@@ -47,7 +47,7 @@ export function getMaxSize(sizeData: SizeData): number {
                 const value = decodeFloatRGB(array[i], array[i + 1], array[i + 2]);
                 if (maxSize < value) maxSize = value;
             }
-            return maxSize / sizeFactor;
+            return maxSize / sizeDataFactor;
     }
 }
 
@@ -103,7 +103,7 @@ export function createInstanceSize(locationIt: LocationIterator, sizeFn: Locatio
     locationIt.reset();
     while (locationIt.hasNext && !locationIt.isNextNewInstance) {
         const v = locationIt.move();
-        encodeFloatRGBtoArray(sizeFn(v.location) * sizeFactor, sizes.array, v.instanceIndex * 3);
+        encodeFloatRGBtoArray(sizeFn(v.location) * sizeDataFactor, sizes.array, v.instanceIndex * 3);
         locationIt.skipInstance();
     }
     return createTextureSize(sizes, 'instance', sizeData);
@@ -116,7 +116,7 @@ export function createGroupSize(locationIt: LocationIterator, sizeFn: LocationSi
     locationIt.reset();
     while (locationIt.hasNext && !locationIt.isNextNewInstance) {
         const v = locationIt.move();
-        encodeFloatRGBtoArray(sizeFn(v.location) * sizeFactor, sizes.array, v.groupIndex * 3);
+        encodeFloatRGBtoArray(sizeFn(v.location) * sizeDataFactor, sizes.array, v.groupIndex * 3);
     }
     return createTextureSize(sizes, 'group', sizeData);
 }
@@ -129,7 +129,7 @@ export function createGroupInstanceSize(locationIt: LocationIterator, sizeFn: Lo
     locationIt.reset();
     while (locationIt.hasNext) {
         const v = locationIt.move();
-        encodeFloatRGBtoArray(sizeFn(v.location) * sizeFactor, sizes.array, v.index * 3);
+        encodeFloatRGBtoArray(sizeFn(v.location) * sizeDataFactor, sizes.array, v.index * 3);
     }
     return createTextureSize(sizes, 'groupInstance', sizeData);
 }

+ 11 - 0
src/mol-io/common/ascii.ts

@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
+ */
+
+export function asciiWrite(data: Uint8Array, str: string) {
+    for (let i = 0, il = str.length; i < il; ++i) {
+        data[i] = str.charCodeAt(i);
+    }
+}

+ 22 - 21
src/mol-model/structure/model/model.ts

@@ -131,32 +131,14 @@ export namespace Model {
         return new ArrayTrajectory(_trajectoryFromModelAndCoordinates(model, coordinates).trajectory);
     }
 
-    export function invertIndex(xs: ArrayLike<number>) {
-        const ret = new Int32Array(xs.length);
-        for (let i = 0, _i = xs.length; i < _i; i++) {
-            ret[xs[i]] = i;
-        }
-        return ret;
-    }
-
     export function trajectoryFromTopologyAndCoordinates(topology: Topology, coordinates: Coordinates): Task<Trajectory> {
         return Task.create('Create Trajectory', async ctx => {
             const models = await createModels(topology.basic, topology.sourceData, ctx);
             if (models.frameCount === 0) throw new Error('found no model');
             const model = models.representative;
-            const { trajectory, srcIndexArray } = _trajectoryFromModelAndCoordinates(model, coordinates);
-
-            // TODO: cache the inverted index somewhere?
-            const invertedIndex = srcIndexArray ? invertIndex(srcIndexArray) : void 0;
-            const pairs = srcIndexArray
-                ? {
-                    indexA: Column.ofIntArray(Column.mapToArray(topology.bonds.indexA, i => invertedIndex![i], Int32Array)),
-                    indexB: Column.ofIntArray(Column.mapToArray(topology.bonds.indexB, i => invertedIndex![i], Int32Array)),
-                    order: topology.bonds.order
-                }
-                : topology.bonds;
-
-            const bondData = { pairs, count: model.atomicHierarchy.atoms._rowCount };
+            const { trajectory } = _trajectoryFromModelAndCoordinates(model, coordinates);
+
+            const bondData = { pairs: topology.bonds, count: model.atomicHierarchy.atoms._rowCount };
             const indexPairBonds = IndexPairBonds.fromData(bondData);
 
             let index = 0;
@@ -176,6 +158,25 @@ export namespace Model {
         return center;
     }
 
+    function invertIndex(xs: Column<number>) {
+        const invertedIndex = new Int32Array(xs.rowCount);
+        let isIdentity = false;
+        for (let i = 0, _i = xs.rowCount; i < _i; i++) {
+            const x = xs.value(i);
+            if (x !== i) isIdentity = false;
+            invertedIndex[x] = i;
+        }
+        return { isIdentity, invertedIndex: invertedIndex as unknown as ArrayLike<ElementIndex> };
+    }
+
+    const InvertedAtomSrcIndexProp = '__InvertedAtomSrcIndex__';
+    export function getInvertedAtomSourceIndex(model: Model): { isIdentity: boolean, invertedIndex: ArrayLike<ElementIndex> } {
+        if (model._staticPropertyData[InvertedAtomSrcIndexProp]) return model._staticPropertyData[InvertedAtomSrcIndexProp];
+        const index = invertIndex(model.atomicHierarchy.atomSourceIndex);
+        model._staticPropertyData[InvertedAtomSrcIndexProp] = index;
+        return index;
+    }
+
     const TrajectoryInfoProp = '__TrajectoryInfo__';
     export type TrajectoryInfo = { readonly index: number, readonly size: number }
     export const TrajectoryInfo = {

+ 9 - 0
src/mol-model/structure/structure/structure.ts

@@ -34,6 +34,7 @@ import { CustomStructureProperty } from '../../../mol-model-props/common/custom-
 import { Trajectory } from '../trajectory';
 import { RuntimeContext, Task } from '../../../mol-task';
 import { computeStructureBoundary } from './util/boundary';
+import { PrincipalAxes } from '../../../mol-math/linear-algebra/matrix/principal-axes';
 
 /** Internal structure state */
 type State = {
@@ -1290,6 +1291,14 @@ namespace Structure {
 
     export type Index = number;
     export const Index = CustomStructureProperty.createSimple<Index>('index', 'root');
+
+    const PrincipalAxesProp = '__PrincipalAxes__';
+    export function getPrincipalAxes(structure: Structure): PrincipalAxes {
+        if (structure.currentPropertyData[PrincipalAxesProp]) return structure.currentPropertyData[PrincipalAxesProp];
+        const principalAxes = StructureElement.Loci.getPrincipalAxes(Structure.toStructureElementLoci(structure));
+        structure.currentPropertyData[PrincipalAxesProp] = principalAxes;
+        return principalAxes;
+    }
 }
 
 export { Structure };

+ 8 - 4
src/mol-model/structure/structure/unit/bonds/common.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2020 Mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2021 Mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -23,6 +23,10 @@ const __ElementIndex: { [e: string]: number | undefined } = { 'H': 0, 'h': 0, 'D
 const __ElementBondThresholds: { [e: number]: number | undefined } = { 0: 1.42, 1: 1.42, 3: 2.7, 4: 2.7, 6: 1.75, 7: 1.6, 8: 1.52, 11: 2.7, 12: 2.7, 13: 2.7, 14: 1.9, 15: 2.0, 16: 1.9, 17: 1.8, 19: 2.7, 20: 2.7, 21: 2.7, 22: 2.7, 23: 2.7, 24: 2.7, 25: 2.7, 26: 2.7, 27: 2.7, 28: 2.7, 29: 2.7, 30: 2.7, 31: 2.7, 33: 2.68, 37: 2.7, 38: 2.7, 39: 2.7, 40: 2.7, 41: 2.7, 42: 2.7, 43: 2.7, 44: 2.7, 45: 2.7, 46: 2.7, 47: 2.7, 48: 2.7, 49: 2.7, 50: 2.7, 55: 2.7, 56: 2.7, 57: 2.7, 58: 2.7, 59: 2.7, 60: 2.7, 61: 2.7, 62: 2.7, 63: 2.7, 64: 2.7, 65: 2.7, 66: 2.7, 67: 2.7, 68: 2.7, 69: 2.7, 70: 2.7, 71: 2.7, 72: 2.7, 73: 2.7, 74: 2.7, 75: 2.7, 76: 2.7, 77: 2.7, 78: 2.7, 79: 2.7, 80: 2.7, 81: 2.7, 82: 2.7, 83: 2.7, 87: 2.7, 88: 2.7, 89: 2.7, 90: 2.7, 91: 2.7, 92: 2.7, 93: 2.7, 94: 2.7, 95: 2.7, 96: 2.7, 97: 2.7, 98: 2.7, 99: 2.7, 100: 2.7, 101: 2.7, 102: 2.7, 103: 2.7, 104: 2.7, 105: 2.7, 106: 2.7, 107: 2.7, 108: 2.7, 109: 2.88 };
 
 /**
+ * Added O-S (316) as 1.8
+ * N-O-S bridge (e.g. LYS-CSO in 7B0L, 6ZWJ, 6ZWH)
+ * (https://www.nature.com/articles/s41586-021-03513-3?s=09)
+ *
  * Increased N-N (112) threshold from 1.55 to 1.6 (e.g. for 0QH in 1BMA)
  *
  * More experimentally observed bond length here (https://cccbdb.nist.gov/expbondlengths1x.asp)
@@ -51,8 +55,8 @@ const __ElementBondThresholds: { [e: number]: number | undefined } = { 0: 1.42,
  * Added P-H (135) as 1.47
  * P-H (https://cccbdb.nist.gov/expbondlengths2x.asp?descript=rPH)
  * - Average    1.423 ((+/- 0.007)
- * - Min        0.912
- * - Max        1.033
+ * - Min        1.414
+ * - Max        1.435
  *
  * Added S-H (152) as 1.45
  * S-H (https://cccbdb.nist.gov/expbondlengths2x.asp?descript=rSH)
@@ -63,7 +67,7 @@ const __ElementBondThresholds: { [e: number]: number | undefined } = { 0: 1.42,
  * Added Si-Si (420) as 2.37
  * (https://cccbdb.nist.gov/expbondlengths2x.asp?descript=rSiSi)
  */
-const __ElementPairThresholds: { [e: number]: number | undefined } = { 0: 0.8, 20: 1.31, 27: 1.2, 35: 1.15, 44: 1.1, 54: 1, 60: 1.84, 72: 1.88, 84: 1.75, 85: 1.56, 86: 1.76, 98: 1.6, 99: 1.68, 100: 1.63, 112: 1.6, 113: 1.59, 114: 1.36, 129: 1.45, 135: 1.47, 144: 1.6, 152: 1.45, 170: 1.4, 180: 1.55, 202: 2.4, 222: 2.24, 224: 1.91, 225: 1.98, 243: 2.02, 269: 2, 293: 1.9, 420: 2.37, 480: 2.3, 512: 2.3, 544: 2.3, 612: 2.1, 629: 1.54, 665: 1, 813: 2.6, 854: 2.27, 894: 1.93, 896: 2.1, 937: 2.05, 938: 2.06, 981: 1.62, 1258: 2.68, 1309: 2.33, 1484: 1, 1763: 2.14, 1823: 2.48, 1882: 2.1, 1944: 1.72, 2380: 2.34, 3367: 2.44, 3733: 2.11, 3819: 2.6, 3821: 2.36, 4736: 2.75, 5724: 2.73, 5959: 2.63, 6519: 2.84, 6750: 2.87, 8991: 2.81 };
+const __ElementPairThresholds: { [e: number]: number | undefined } = { 0: 0.8, 20: 1.31, 27: 1.2, 35: 1.15, 44: 1.1, 54: 1, 60: 1.84, 72: 1.88, 84: 1.75, 85: 1.56, 86: 1.76, 98: 1.6, 99: 1.68, 100: 1.63, 112: 1.6, 113: 1.59, 114: 1.36, 129: 1.45, 135: 1.47, 144: 1.6, 152: 1.45, 170: 1.4, 180: 1.55, 202: 2.4, 222: 2.24, 224: 1.91, 225: 1.98, 243: 2.02, 269: 2, 293: 1.9, 316: 1.8, 420: 2.37, 480: 2.3, 512: 2.3, 544: 2.3, 612: 2.1, 629: 1.54, 665: 1, 813: 2.6, 854: 2.27, 894: 1.93, 896: 2.1, 937: 2.05, 938: 2.06, 981: 1.62, 1258: 2.68, 1309: 2.33, 1484: 1, 1763: 2.14, 1823: 2.48, 1882: 2.1, 1944: 1.72, 2380: 2.34, 3367: 2.44, 3733: 2.11, 3819: 2.6, 3821: 2.36, 4736: 2.75, 5724: 2.73, 5959: 2.63, 6519: 2.84, 6750: 2.87, 8991: 2.81 };
 
 const __DefaultBondingRadius = 2.001;
 

+ 9 - 3
src/mol-model/structure/structure/unit/bonds/inter-compute.ts

@@ -19,6 +19,7 @@ import { IndexPairBonds } from '../../../../../mol-model-formats/structure/prope
 import { InterUnitGraph } from '../../../../../mol-math/graph/inter-unit-graph';
 import { StructConn } from '../../../../../mol-model-formats/structure/property/bonds/struct_conn';
 import { equalEps } from '../../../../../mol-math/linear-algebra/3d/common';
+import { Model } from '../../../model';
 
 const MAX_RADIUS = 4;
 
@@ -48,7 +49,10 @@ function findPairBonds(unitA: Unit.Atomic, unitB: Unit.Atomic, props: BondComput
     const hasOccupancy = occupancyA.isDefined && occupancyB.isDefined;
 
     const structConn = unitA.model === unitB.model && StructConn.Provider.get(unitA.model);
-    const indexPairs = unitA.model === unitB.model && IndexPairBonds.Provider.get(unitA.model);
+    const indexPairs = !props.forceCompute && unitA.model === unitB.model && IndexPairBonds.Provider.get(unitA.model);
+
+    const { atomSourceIndex: sourceIndex } = unitA.model.atomicHierarchy;
+    const { invertedIndex } = indexPairs ? Model.getInvertedAtomSourceIndex(unitB.model) : { invertedIndex: void 0 };
 
     const structConnExhaustive = unitA.model === unitB.model && StructConn.isExhaustive(unitA.model);
 
@@ -70,8 +74,10 @@ function findPairBonds(unitA: Unit.Atomic, unitB: Unit.Atomic, props: BondComput
 
         if (!props.forceCompute && indexPairs) {
             const { order, distance, flag } = indexPairs.edgeProps;
-            for (let i = indexPairs.offset[aI], il = indexPairs.offset[aI + 1]; i < il; ++i) {
-                const bI = indexPairs.b[i];
+
+            const srcA = sourceIndex.value(aI);
+            for (let i = indexPairs.offset[srcA], il = indexPairs.offset[srcA + 1]; i < il; ++i) {
+                const bI = invertedIndex![indexPairs.b[i]];
 
                 const _bI = SortedArray.indexOf(unitB.elements, bI) as StructureElement.UnitIndex;
                 if (_bI < 0) continue;

+ 7 - 2
src/mol-model/structure/structure/unit/bonds/intra-compute.ts

@@ -51,6 +51,9 @@ function findIndexPairBonds(unit: Unit.Atomic) {
     const atomCount = unit.elements.length;
     const { edgeProps } = indexPairs;
 
+    const { atomSourceIndex: sourceIndex } = unit.model.atomicHierarchy;
+    const { invertedIndex } = Model.getInvertedAtomSourceIndex(unit.model);
+
     const atomA: StructureElement.UnitIndex[] = [];
     const atomB: StructureElement.UnitIndex[] = [];
     const flags: number[] = [];
@@ -60,8 +63,10 @@ function findIndexPairBonds(unit: Unit.Atomic) {
         const aI =  atoms[_aI];
         const isHa = type_symbol.value(aI) === 'H';
 
-        for (let i = indexPairs.offset[aI], il = indexPairs.offset[aI + 1]; i < il; ++i) {
-            const bI = indexPairs.b[i];
+        const srcA = sourceIndex.value(aI);
+
+        for (let i = indexPairs.offset[srcA], il = indexPairs.offset[srcA + 1]; i < il; ++i) {
+            const bI = invertedIndex[indexPairs.b[i]];
             if (aI >= bI) continue;
 
             const _bI = SortedArray.indexOf(unit.elements, bI) as StructureElement.UnitIndex;

+ 73 - 0
src/mol-plugin-state/animation/built-in/spin-structure.ts

@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+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 AnimateStructureSpin = PluginStateAnimation.create({
+    name: 'built-in.animate-structure-spin',
+    display: { name: 'Spin Structure' },
+    isExportable: true,
+    params: () => ({
+        durationInMs: PD.Numeric(3000, { min: 100, max: 10000, step: 100})
+    }),
+    initialState: () => ({ t: 0 }),
+    getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
+    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 spins = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.SpinStructureRepresentation3D, r.transform.ref));
+            if (spins.length > 0) continue;
+
+            changed = true;
+            update.to(r.transform.ref)
+                .apply(StateTransforms.Representation.SpinStructureRepresentation3D, { t: 0 }, { tags: 'animate-structure-spin' });
+        }
+
+        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-structure-spin'));
+        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.SpinStructureRepresentation3D));
+
+        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({ ...m.params?.values, t: newTime });
+        }
+
+        await PluginCommands.State.Update(ctx.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+
+        return { kind: 'next', state: { t: newTime } };
+    }
+});

+ 59 - 1
src/mol-plugin-state/animation/helpers.ts

@@ -1,9 +1,11 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { SymmetryOperator } from '../../mol-math/geometry';
 import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
 import { Structure } from '../../mol-model/structure';
@@ -32,4 +34,60 @@ export function explodeStructure(structure: Structure, unitTransforms: Structure
 
         unitTransforms.setTransform(_transMat, u);
     }
+}
+
+//
+
+export const SpinStructureParams = {
+    axis: PD.MappedStatic('custom', {
+        structure: PD.Group({
+            principalAxis: PD.Select('dirA', [['dirA', 'A'], ['dirB', 'B'], ['dirC', 'C']] as const)
+        }),
+        custom: PD.Group({
+            vector: PD.Vec3(Vec3.create(0, 0, 1))
+        })
+    }),
+    origin: PD.MappedStatic('structure', {
+        structure: PD.Group({}),
+        custom: PD.Group({
+            vector: PD.Vec3(Vec3.create(0, 0, 0))
+        })
+    }),
+};
+export type SpinStructureProps = PD.Values<typeof SpinStructureParams>
+
+export function getSpinStructureAxisAndOrigin(structure: Structure, props: SpinStructureProps) {
+    let axis: Vec3, origin: Vec3;
+
+    if (props.axis.name === 'custom') {
+        axis = props.axis.params.vector;
+    } else {
+        const pa = Structure.getPrincipalAxes(structure);
+        axis = pa.momentsAxes[props.axis.params.principalAxis];
+    }
+
+    if (props.origin.name === 'custom') {
+        origin = props.origin.params.vector;
+    } else {
+        const pa = Structure.getPrincipalAxes(structure);
+        origin = pa.momentsAxes.origin;
+    }
+
+    return { axis, origin };
+}
+
+const _rotMat = Mat4();
+const _transMat2 = Mat4();
+const _t = Mat4();
+export function spinStructure(structure: Structure, unitTransforms: StructureUnitTransforms, t: number, axis: Vec3, origin: Vec3) {
+    for (let i = 0, _i = structure.units.length; i < _i; i++) {
+        const u = structure.units[i];
+        Vec3.negate(_transVec, origin);
+        Mat4.fromTranslation(_transMat, _transVec);
+        Mat4.fromRotation(_rotMat, Math.PI * t * 2, axis);
+        Mat4.fromTranslation(_transMat2, origin);
+        Mat4.mul(_t, _rotMat, _transMat);
+        Mat4.mul(_t, _transMat2, _t);
+        unitTransforms.setTransform(_t, u);
+    }
 }

+ 42 - 6
src/mol-plugin-state/helpers/structure-selection-query.ts

@@ -374,18 +374,53 @@ const connectedOnly = StructureSelectionQuery('Connected to Ligand or Carbohydra
 ]), { category: StructureSelectionCategory.Internal, isHidden: true });
 
 const disulfideBridges = StructureSelectionQuery('Disulfide Bridges', MS.struct.modifier.union([
-    MS.struct.modifier.wholeResidues([
+    MS.struct.combinator.merge([
         MS.struct.modifier.union([
-            MS.struct.generator.bondedAtomicPairs({
-                0: MS.core.flags.hasAny([
-                    MS.struct.bondProperty.flags(),
-                    MS.core.type.bitflags([BondType.Flag.Disulfide])
+            MS.struct.modifier.wholeResidues([
+                MS.struct.filter.isConnectedTo({
+                    0: MS.struct.generator.atomGroups({
+                        'residue-test': MS.core.set.has([MS.set('CYS'), MS.ammp('auth_comp_id')]),
+                        'atom-test': MS.core.set.has([MS.set('SG'), MS.ammp('label_atom_id')])
+                    }),
+                    target: MS.struct.generator.atomGroups({
+                        'residue-test': MS.core.set.has([MS.set('CYS'), MS.ammp('auth_comp_id')]),
+                        'atom-test': MS.core.set.has([MS.set('SG'), MS.ammp('label_atom_id')])
+                    }),
+                    'bond-test': true
+                })
+            ])
+        ]),
+        MS.struct.modifier.union([
+            MS.struct.modifier.wholeResidues([
+                MS.struct.modifier.union([
+                    MS.struct.generator.bondedAtomicPairs({
+                        0: MS.core.flags.hasAny([
+                            MS.struct.bondProperty.flags(),
+                            MS.core.type.bitflags([BondType.Flag.Disulfide])
+                        ])
+                    })
                 ])
-            })
+            ])
         ])
     ])
 ]), { category: StructureSelectionCategory.Bond });
 
+const nosBridges = StructureSelectionQuery('NOS Bridges', MS.struct.modifier.union([
+    MS.struct.modifier.wholeResidues([
+        MS.struct.filter.isConnectedTo({
+            0: MS.struct.generator.atomGroups({
+                'residue-test': MS.core.set.has([MS.set('CSO', 'LYS'), MS.ammp('auth_comp_id')]),
+                'atom-test': MS.core.set.has([MS.set('OD', 'NZ'), MS.ammp('label_atom_id')])
+            }),
+            target: MS.struct.generator.atomGroups({
+                'residue-test': MS.core.set.has([MS.set('CSO', 'LYS'), MS.ammp('auth_comp_id')]),
+                'atom-test': MS.core.set.has([MS.set('OD', 'NZ'), MS.ammp('label_atom_id')])
+            }),
+            'bond-test': true
+        })
+    ])
+]), { category: StructureSelectionCategory.Bond });
+
 const nonStandardPolymer = StructureSelectionQuery('Non-standard Residues in Polymers', MS.struct.modifier.union([
     MS.struct.generator.atomGroups({
         'entity-test': MS.core.rel.eq([MS.ammp('entityType'), 'polymer']),
@@ -652,6 +687,7 @@ export const StructureSelectionQueries = {
     ligandConnectedOnly,
     connectedOnly,
     disulfideBridges,
+    nosBridges,
     nonStandardPolymer,
     coarse,
     ring,

+ 2 - 2
src/mol-plugin-state/helpers/structure-transparency.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
@@ -70,5 +70,5 @@ async function eachRepr(plugin: PluginContext, components: StructureComponentRef
 function getFilteredBundle(layers: Transparency.BundleLayer[], structure: Structure) {
     const transparency = Transparency.ofBundle(layers, structure.root);
     const merged = Transparency.merge(transparency);
-    return Transparency.filter(merged, structure);
+    return Transparency.filter(merged, structure) as Transparency<StructureElement.Loci>;
 }

+ 45 - 2
src/mol-plugin-state/transforms/representation.ts

@@ -20,7 +20,7 @@ import { PluginStateObject as SO, PluginStateTransform } from '../objects';
 import { ColorNames } from '../../mol-util/color/names';
 import { ShapeRepresentation } from '../../mol-repr/shape/representation';
 import { StructureUnitTransforms } from '../../mol-model/structure/structure/util/unit-transforms';
-import { unwindStructureAssembly, explodeStructure } from '../animation/helpers';
+import { unwindStructureAssembly, explodeStructure, spinStructure, SpinStructureParams, getSpinStructureAxisAndOrigin } from '../animation/helpers';
 import { Color } from '../../mol-util/color';
 import { Overpaint } from '../../mol-theme/overpaint';
 import { Transparency } from '../../mol-theme/transparency';
@@ -43,6 +43,7 @@ import { Box3D } from '../../mol-math/geometry';
 
 export { StructureRepresentation3D };
 export { ExplodeStructureRepresentation3D };
+export { SpinStructureRepresentation3D };
 export { UnwindStructureAssemblyRepresentation3D };
 export { OverpaintStructureRepresentation3DFromScript };
 export { OverpaintStructureRepresentation3DFromBundle };
@@ -222,7 +223,6 @@ const UnwindStructureAssemblyRepresentation3D = PluginStateTransform.BuiltIn({
     }
 });
 
-
 type ExplodeStructureRepresentation3D = typeof ExplodeStructureRepresentation3D
 const ExplodeStructureRepresentation3D = PluginStateTransform.BuiltIn({
     name: 'explode-structure-representation-3d',
@@ -259,6 +259,49 @@ const ExplodeStructureRepresentation3D = PluginStateTransform.BuiltIn({
     }
 });
 
+type SpinStructureRepresentation3D = typeof SpinStructureRepresentation3D
+const SpinStructureRepresentation3D = PluginStateTransform.BuiltIn({
+    name: 'spin-structure-representation-3d',
+    display: 'Spin 3D Representation',
+    from: SO.Molecule.Structure.Representation3D,
+    to: SO.Molecule.Structure.Representation3DState,
+    params: {
+        t: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }),
+        ...SpinStructureParams
+    }
+})({
+    canAutoUpdate() {
+        return true;
+    },
+    apply({ a, params }) {
+        const structure = a.data.sourceData;
+        const unitTransforms = new StructureUnitTransforms(structure.root);
+
+        const { axis, origin } = getSpinStructureAxisAndOrigin(structure.root, params);
+        spinStructure(structure, unitTransforms, params.t, axis, origin);
+        return new SO.Molecule.Structure.Representation3DState({
+            state: { unitTransforms },
+            initialState: { unitTransforms: new StructureUnitTransforms(structure.root) },
+            info: structure.root,
+            repr: a.data.repr
+        }, { label: `Spin T = ${params.t.toFixed(2)}` });
+    },
+    update({ a, b, newParams, oldParams }) {
+        const structure = a.data.sourceData;
+        if (b.data.info !== structure.root) return StateTransformer.UpdateResult.Recreate;
+        if (a.data.repr !== b.data.repr) return StateTransformer.UpdateResult.Recreate;
+
+        if (oldParams.t === newParams.t && oldParams.axis === newParams.axis && oldParams.origin === newParams.origin) return StateTransformer.UpdateResult.Unchanged;
+
+        const unitTransforms = b.data.state.unitTransforms!;
+        const { axis, origin } = getSpinStructureAxisAndOrigin(structure.root, newParams);
+        spinStructure(structure.root, unitTransforms, newParams.t, axis, origin);
+        b.label = `Spin T = ${newParams.t.toFixed(2)}`;
+        b.data.repr = a.data.repr;
+        return StateTransformer.UpdateResult.Updated;
+    }
+});
+
 type OverpaintStructureRepresentation3DFromScript = typeof OverpaintStructureRepresentation3DFromScript
 const OverpaintStructureRepresentation3DFromScript = PluginStateTransform.BuiltIn({
     name: 'overpaint-structure-representation-3d-from-script',

+ 3 - 0
src/mol-plugin/spec.ts

@@ -22,6 +22,7 @@ import { AssignColorVolume } from '../mol-plugin-state/actions/volume';
 import { StateTransforms } from '../mol-plugin-state/transforms';
 import { BoxifyVolumeStreaming, CreateVolumeStreamingBehavior, InitVolumeStreaming } from '../mol-plugin/behavior/dynamic/volume-streaming/transformers';
 import { AnimateStateInterpolation } from '../mol-plugin-state/animation/built-in/state-interpolation';
+import { AnimateStructureSpin } from '../mol-plugin-state/animation/built-in/spin-structure';
 
 export { PluginSpec };
 
@@ -96,6 +97,7 @@ export const DefaultPluginSpec = (): PluginSpec => ({
         PluginSpec.Action(StateTransforms.Representation.ModelUnitcell3D),
         PluginSpec.Action(StateTransforms.Representation.StructureBoundingBox3D),
         PluginSpec.Action(StateTransforms.Representation.ExplodeStructureRepresentation3D),
+        PluginSpec.Action(StateTransforms.Representation.SpinStructureRepresentation3D),
         PluginSpec.Action(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D),
         PluginSpec.Action(StateTransforms.Representation.OverpaintStructureRepresentation3DFromScript),
         PluginSpec.Action(StateTransforms.Representation.TransparencyStructureRepresentation3DFromScript),
@@ -128,6 +130,7 @@ export const DefaultPluginSpec = (): PluginSpec => ({
         AnimateCameraSpin,
         AnimateStateSnapshots,
         AnimateAssemblyUnwind,
+        AnimateStructureSpin,
         AnimateStateInterpolation
     ]
 });

+ 63 - 49
src/mol-theme/transparency.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -10,15 +10,18 @@ import { Script } from '../mol-script/script';
 
 export { Transparency };
 
-type Transparency = { readonly layers: ReadonlyArray<Transparency.Layer> }
+type Transparency<T extends Loci = Loci> = {
+    readonly kind: T['kind']
+    readonly layers: ReadonlyArray<Transparency.Layer<T>>
+}
 
-function Transparency(layers: ReadonlyArray<Transparency.Layer>): Transparency {
-    return { layers };
+function Transparency<T extends Loci>(kind: T['kind'], layers: ReadonlyArray<Transparency.Layer<T>>): Transparency<T> {
+    return { kind, layers };
 }
 
 namespace Transparency {
-    export type Layer = { readonly loci: StructureElement.Loci, readonly value: number }
-    export const Empty: Transparency = { layers: [] };
+    export type Layer<T extends Loci = Loci> = { readonly loci: T, readonly value: number }
+    export const Empty: Transparency = { kind: 'empty-loci', layers: [] };
 
     export type Variant = 'single' | 'multi'
 
@@ -36,61 +39,72 @@ namespace Transparency {
         return transparency.layers.length === 0;
     }
 
-    export function remap(transparency: Transparency, structure: Structure) {
-        const layers: Transparency.Layer[] = [];
-        for (const layer of transparency.layers) {
-            let { loci, value } = layer;
-            loci = StructureElement.Loci.remap(loci, structure);
-            if (!StructureElement.Loci.isEmpty(loci)) {
-                layers.push({ loci, value });
+    export function remap(transparency: Transparency, structure: Structure): Transparency {
+        if (transparency.kind === 'element-loci') {
+            const layers: Transparency.Layer[] = [];
+            for (const layer of transparency.layers) {
+                const loci = StructureElement.Loci.remap(layer.loci as StructureElement.Loci, structure);
+                if (!StructureElement.Loci.isEmpty(loci)) {
+                    layers.push({ loci, value: layer.value });
+                }
             }
+            return { kind: 'element-loci', layers };
+        } else {
+            return transparency;
         }
-        return { layers };
     }
 
     export function merge(transparency: Transparency): Transparency {
         if (isEmpty(transparency)) return transparency;
-        const { structure } = transparency.layers[0].loci;
-        const map = new Map<number, StructureElement.Loci>();
-        let shadowed = StructureElement.Loci.none(structure);
-        for (let i = 0, il = transparency.layers.length; i < il; ++i) {
-            let { loci, value } = transparency.layers[il - i - 1]; // process from end
-            loci = StructureElement.Loci.subtract(loci, shadowed);
-            shadowed = StructureElement.Loci.union(loci, shadowed);
-            if (!StructureElement.Loci.isEmpty(loci)) {
-                if (map.has(value)) {
-                    loci = StructureElement.Loci.union(loci, map.get(value)!);
+        if (transparency.kind === 'element-loci') {
+            const { structure } = transparency.layers[0].loci as StructureElement.Loci;
+            const map = new Map<number, StructureElement.Loci>();
+            let shadowed = StructureElement.Loci.none(structure);
+            for (let i = 0, il = transparency.layers.length; i < il; ++i) {
+                let { loci, value } = transparency.layers[il - i - 1]; // process from end
+                loci = StructureElement.Loci.subtract(loci as StructureElement.Loci, shadowed);
+                shadowed = StructureElement.Loci.union(loci, shadowed);
+                if (!StructureElement.Loci.isEmpty(loci)) {
+                    if (map.has(value)) {
+                        loci = StructureElement.Loci.union(loci, map.get(value)!);
+                    }
+                    map.set(value, loci);
                 }
-                map.set(value, loci);
             }
+            const layers: Transparency.Layer<StructureElement.Loci>[] = [];
+            map.forEach((loci, value) => {
+                layers.push({ loci, value });
+            });
+            return { kind: 'element-loci', layers };
+        } else {
+            return transparency;
         }
-        const layers: Transparency.Layer[] = [];
-        map.forEach((loci, value) => {
-            layers.push({ loci, value });
-        });
-        return { layers };
     }
 
     export function filter(transparency: Transparency, filter: Structure): Transparency {
         if (isEmpty(transparency)) return transparency;
-        const { structure } = transparency.layers[0].loci;
-        const layers: Transparency.Layer[] = [];
-        for (const layer of transparency.layers) {
-            let { loci, value } = layer;
-            // filter by first map to the `filter` structure and
-            // then map back to the original structure of the transparency loci
-            const filtered = StructureElement.Loci.remap(loci, filter);
-            loci = StructureElement.Loci.remap(filtered, structure);
-            if (!StructureElement.Loci.isEmpty(loci)) {
-                layers.push({ loci, value });
+        if (transparency.kind === 'element-loci') {
+            const { structure } = transparency.layers[0].loci as StructureElement.Loci;
+            const layers: Transparency.Layer<StructureElement.Loci>[] = [];
+            for (const layer of transparency.layers) {
+                let { loci, value } = layer;
+                // filter by first map to the `filter` structure and
+                // then map back to the original structure of the transparency loci
+                const filtered = StructureElement.Loci.remap(loci as StructureElement.Loci, filter);
+                loci = StructureElement.Loci.remap(filtered, structure);
+                if (!StructureElement.Loci.isEmpty(loci)) {
+                    layers.push({ loci, value });
+                }
             }
+            return { kind: 'element-loci', layers };
+        } else {
+            return transparency;
         }
-        return { layers };
     }
 
     export type ScriptLayer = { script: Script, value: number }
-    export function ofScript(scriptLayers: ScriptLayer[], structure: Structure): Transparency {
-        const layers: Transparency.Layer[] = [];
+    export function ofScript(scriptLayers: ScriptLayer[], structure: Structure): Transparency<StructureElement.Loci> {
+        const layers: Transparency.Layer<StructureElement.Loci>[] = [];
         for (let i = 0, il = scriptLayers.length; i < il; ++i) {
             const { script, value } = scriptLayers[i];
             const loci = Script.toLoci(script, structure);
@@ -98,27 +112,27 @@ namespace Transparency {
                 layers.push({ loci, value });
             }
         }
-        return { layers };
+        return { kind: 'element-loci', layers };
     }
 
     export type BundleLayer = { bundle: StructureElement.Bundle, value: number }
-    export function ofBundle(bundleLayers: BundleLayer[], structure: Structure): Transparency {
-        const layers: Transparency.Layer[] = [];
+    export function ofBundle(bundleLayers: BundleLayer[], structure: Structure): Transparency<StructureElement.Loci> {
+        const layers: Transparency.Layer<StructureElement.Loci>[] = [];
         for (let i = 0, il = bundleLayers.length; i < il; ++i) {
             const { bundle, value } = bundleLayers[i];
             const loci = StructureElement.Bundle.toLoci(bundle, structure.root);
             layers.push({ loci, value });
         }
-        return { layers };
+        return { kind: 'element-loci', layers };
     }
 
-    export function toBundle(transparency: Transparency) {
+    export function toBundle(transparency: Transparency<StructureElement.Loci>) {
         const layers: BundleLayer[] = [];
         for (let i = 0, il = transparency.layers.length; i < il; ++i) {
             let { loci, value } = transparency.layers[i];
             const bundle = StructureElement.Bundle.fromLoci(loci);
             layers.push({ bundle, value });
         }
-        return { layers };
+        return { kind: 'element-loci', layers };
     }
 }