Browse Source

Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/252

Alexander Rose 3 years ago
parent
commit
94fd5a97d6
82 changed files with 1130 additions and 14456 deletions
  1. 2 1
      .eslintrc.json
  2. 2 2
      .github/workflows/lint.yml
  3. 53 13
      CHANGELOG.md
  4. 2 14094
      package-lock.json
  5. 18 18
      package.json
  6. 10 0
      src/apps/viewer/index.html
  7. 8 4
      src/apps/viewer/index.ts
  8. 25 3
      src/mol-canvas3d/camera.ts
  9. 16 18
      src/mol-canvas3d/camera/util.ts
  10. 12 5
      src/mol-canvas3d/canvas3d.ts
  11. 47 5
      src/mol-canvas3d/helper/interaction-events.ts
  12. 1 0
      src/mol-canvas3d/passes/draw.ts
  13. 17 3
      src/mol-canvas3d/passes/pick.ts
  14. 10 2
      src/mol-geo/geometry/direct-volume/direct-volume.ts
  15. 5 2
      src/mol-geo/geometry/image/image.ts
  16. 1 1
      src/mol-geo/geometry/lines/lines.ts
  17. 14 12
      src/mol-geo/geometry/points/points.ts
  18. 6 2
      src/mol-geo/geometry/texture-mesh/texture-mesh.ts
  19. 1 2
      src/mol-gl/_spec/renderer.spec.ts
  20. 4 4
      src/mol-gl/renderable/points.ts
  21. 1 1
      src/mol-gl/renderable/schema.ts
  22. 12 4
      src/mol-gl/renderer.ts
  23. 2 2
      src/mol-gl/shader/chunks/apply-marker-color.glsl.ts
  24. 1 2
      src/mol-gl/shader/chunks/assign-material-color.glsl.ts
  25. 1 0
      src/mol-gl/shader/chunks/common-frag-params.glsl.ts
  26. 1 0
      src/mol-gl/shader/direct-volume.frag.ts
  27. 10 7
      src/mol-gl/shader/lines.vert.ts
  28. 11 9
      src/mol-gl/shader/points.frag.ts
  29. 4 3
      src/mol-gl/shader/points.vert.ts
  30. 3 3
      src/mol-gl/shader/text.vert.ts
  31. 2 2
      src/mol-gl/webgl/context.ts
  32. 10 1
      src/mol-io/reader/common/text/column/token.ts
  33. 9 2
      src/mol-math/geometry/primitives/axes3d.ts
  34. 11 3
      src/mol-math/linear-algebra/3d/vec3.ts
  35. 14 3
      src/mol-math/linear-algebra/tensor.ts
  36. 25 1
      src/mol-math/misc.ts
  37. 2 2
      src/mol-model-formats/structure/basic/atomic.ts
  38. 3 2
      src/mol-model-formats/structure/pdb/atom-site.ts
  39. 27 3
      src/mol-model-formats/structure/pdb/entity.ts
  40. 1 1
      src/mol-model-formats/structure/pdb/secondary-structure.ts
  41. 2 1
      src/mol-model-props/computed/helix-orientation/helix-orientation.ts
  42. 3 3
      src/mol-model/loci.ts
  43. 12 0
      src/mol-model/structure/export/categories/misc.ts
  44. 5 2
      src/mol-model/structure/export/mmcif.ts
  45. 39 5
      src/mol-model/structure/structure/carbohydrates/constants.ts
  46. 14 0
      src/mol-model/structure/structure/element/loci.ts
  47. 2 2
      src/mol-model/structure/structure/unit.ts
  48. 7 2
      src/mol-model/structure/structure/unit/bonds.ts
  49. 2 2
      src/mol-model/structure/structure/unit/bonds/intra-compute.ts
  50. 12 10
      src/mol-plugin-state/manager/interactivity.ts
  51. 48 10
      src/mol-plugin-state/manager/structure/measurement.ts
  52. 4 9
      src/mol-plugin-state/manager/structure/selection.ts
  53. 7 3
      src/mol-plugin-state/transforms/helpers.ts
  54. 2 1
      src/mol-plugin-state/transforms/model.ts
  55. 36 2
      src/mol-plugin-state/transforms/representation.ts
  56. 2 2
      src/mol-plugin-ui/controls/line-graph/line-graph-component.tsx
  57. 5 1
      src/mol-plugin-ui/sequence/sequence.tsx
  58. 30 7
      src/mol-plugin-ui/structure/measurements.tsx
  59. 26 10
      src/mol-plugin/behavior/dynamic/representation.ts
  60. 1 1
      src/mol-plugin/behavior/dynamic/selection/structure-focus-representation.ts
  61. 14 0
      src/mol-plugin/config.ts
  62. 4 1
      src/mol-plugin/context.ts
  63. 9 4
      src/mol-repr/representation.ts
  64. 45 57
      src/mol-repr/shape/loci/orientation.ts
  65. 84 0
      src/mol-repr/shape/loci/plane.ts
  66. 14 5
      src/mol-repr/structure/representation/line.ts
  67. 2 2
      src/mol-repr/structure/representation/point.ts
  68. 20 7
      src/mol-repr/structure/visual/bond-inter-unit-cylinder.ts
  69. 14 6
      src/mol-repr/structure/visual/bond-inter-unit-line.ts
  70. 20 5
      src/mol-repr/structure/visual/bond-intra-unit-cylinder.ts
  71. 13 4
      src/mol-repr/structure/visual/bond-intra-unit-line.ts
  72. 94 0
      src/mol-repr/structure/visual/element-cross.ts
  73. 1 1
      src/mol-repr/structure/visual/element-point.ts
  74. 2 1
      src/mol-repr/structure/visual/util/bond.ts
  75. 96 32
      src/mol-repr/structure/visual/util/link.ts
  76. 8 0
      src/mol-theme/label.ts
  77. 10 2
      src/mol-util/marker-action.ts
  78. 1 1
      src/mol-util/zip/bin.ts
  79. 3 0
      src/servers/model/CHANGELOG.md
  80. 5 5
      src/servers/model/config.ts
  81. 2 2
      src/servers/model/version.ts
  82. 6 6
      src/servers/volume/config.ts

+ 2 - 1
.eslintrc.json

@@ -54,7 +54,8 @@
         "no-multi-spaces": "error",
         "block-spacing": "error",
         "keyword-spacing": "off",
-        "space-before-blocks": "error"
+        "space-before-blocks": "error",
+        "semi-spacing": "error"
     },
     "overrides": [
         {

+ 2 - 2
.github/workflows/lint.yml

@@ -8,10 +8,10 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v1
-    - name: install node v12
+    - name: install node v14
       uses: actions/setup-node@v1
       with:
-        node-version: 12
+        node-version: 14
     - name: yarn install
       run: yarn install
     - name: eslint

+ 53 - 13
CHANGELOG.md

@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file, following t
 Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here.
 
 
+## [Unreleased]
+
+- Add additional measurement controls: orientation (box, axes, ellipsoid) & plane (best fit)
+- Improve aromatic bond visuals (add ``aromaticScale``, ``aromaticSpacing``, ``aromaticDashCount`` params)
+- [Breaking] Change ``adjustCylinderLength`` default to ``false`` (set to true for focus representation)
+- Fix marker highlight color overriding select color
+
+## [v2.3.5] - 2021-10-19
+
+- Fix sequence viewer for PDB files with COMPND record and multichain entities.
+- Fix index pair bonds order assignment
+
+## [v2.3.4] - 2021-10-12
+
+- Fix pickScale not taken into account in line/point shader
+- Add pixel-scale, pick-scale & pick-padding GET params to Viewer app
+- Fix selecting bonds not adding their atoms in selection manager
+- Add ``preferAtoms`` option to SelectLoci/HighlightLoci behaviors
+- Make the implicit atoms of bond visuals pickable
+    - Add ``preferAtomPixelPadding`` to Canvas3dInteractionHelper
+- Add points & crosses visuals to Line representation
+- Add ``pickPadding`` config option (look around in case target pixel is empty)
+- Add ``multipleBonds`` param to bond visuals with options: off, symmetric, offset
+- Fix ``argparse`` config in servers.
+
+## [v2.3.3] - 2021-10-01
+
+- Fix direct volume shader
+
+## [v2.3.2] - 2021-10-01
+
+- Prefer WebGL1 on iOS devices until WebGL2 support has stabilized.
+
+## [v2.3.1] - 2021-09-28
+
+- Add Charmm saccharide names
+- Treat missing occupancy column as occupancy of 1
+- Fix line shader not accounting for aspect ratio
+- [Breaking] Fix point repr & shader
+    - Was unusable with ``wboit``
+    - Replaced ``pointFilledCircle`` & ``pointEdgeBleach`` params by ``pointStyle`` (square, circle, fuzzy)
+    - Set ``pointSizeAttenuation`` to false by default
+    - Set ``sizeTheme`` to ``uniform`` by default
+- Add ``markerPriority`` option to Renderer (useful in combination with edges of marking pass)
+- Add support support for ``chem_comp_bond`` and ``struct_conn`` categories (fixes ModelServer behavior where these categories should have been present)
+- Model and VolumeServer: fix argparse config
+
 ## [v2.3.0] - 2021-09-06
 
 - Take include/exclude flags into account when displaying aromatic bonds
@@ -111,29 +158,22 @@ Note that since we don't clearly distinguish between a public and private interf
 - Fixed Measurements UI labels (#166)
 
 ## [v2.0.3] - 2021-04-09
-### Added
-- Support for ``ColorTheme.palette`` designed for providing gradient-like coloring.
 
-### Changed
+- Add support for ``ColorTheme.palette`` designed for providing gradient-like coloring.
 - [Breaking] The ``zip`` function is now asynchronous and expects a ``RuntimeContext``. Also added ``Zip()`` returning a ``Task``.
 - [Breaking] Add ``CubeGridFormat`` in ``alpha-orbitals`` extension.
 
 ## [v2.0.2] - 2021-03-29
-### Added
-- ``Canvas3D.getRenderObjects``.
-- [WIP] Animate state interpolating, including model trajectories
 
-### Changed
+- Add ``Canvas3D.getRenderObjects``.
+- [WIP] Animate state interpolating, including model trajectories
 - Recognise MSE, SEP, TPO, PTR and PCA as non-standard amino-acids.
-
-### Fixed
-- VolumeFromDensityServerCif transform label
-
+- Fix VolumeFromDensityServerCif transform label
 
 ## [v2.0.1] - 2021-03-23
-### Fixed
-- Exclude tsconfig.commonjs.tsbuildinfo from npm bundle
 
+- Exclude tsconfig.commonjs.tsbuildinfo from npm bundle
 
 ## [v2.0.0] - 2021-03-23
+
 Too many changes to list as this is the start of the changelog... Notably, default exports are now forbidden.

File diff suppressed because it is too large
+ 2 - 14094
package-lock.json


+ 18 - 18
package.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "2.3.0",
+  "version": "2.3.5",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -94,33 +94,33 @@
     "@graphql-codegen/typescript": "^2.2.2",
     "@graphql-codegen/typescript-graphql-files-modules": "^2.1.0",
     "@graphql-codegen/typescript-graphql-request": "^4.1.4",
-    "@graphql-codegen/typescript-operations": "^2.1.4",
+    "@graphql-codegen/typescript-operations": "^2.1.6",
     "@types/cors": "^2.8.12",
-    "@typescript-eslint/eslint-plugin": "^4.31.0",
-    "@typescript-eslint/parser": "^4.31.0",
+    "@typescript-eslint/eslint-plugin": "^4.32.0",
+    "@typescript-eslint/parser": "^4.32.0",
     "benchmark": "^2.1.4",
-    "concurrently": "^6.2.1",
-    "cpx2": "^3.0.2",
+    "concurrently": "^6.3.0",
+    "cpx2": "^4.0.0",
     "crypto-browserify": "^3.12.0",
-    "css-loader": "^6.2.0",
+    "css-loader": "^6.3.0",
     "eslint": "^7.32.0",
     "extra-watch-webpack-plugin": "^1.0.3",
     "file-loader": "^6.2.0",
     "fs-extra": "^10.0.0",
-    "graphql": "^15.5.3",
-    "http-server": "^13.0.1",
-    "jest": "^27.1.1",
+    "graphql": "^15.6.0",
+    "http-server": "^13.0.2",
+    "jest": "^27.2.4",
     "mini-css-extract-plugin": "^2.3.0",
     "node-sass": "^6.0.1",
     "path-browserify": "^1.0.1",
     "raw-loader": "^4.0.2",
     "sass-loader": "^12.1.0",
-    "simple-git": "^2.45.1",
+    "simple-git": "^2.46.0",
     "stream-browserify": "^3.0.0",
-    "style-loader": "^3.2.1",
+    "style-loader": "^3.3.0",
     "ts-jest": "^27.0.5",
     "typescript": "^4.4.3",
-    "webpack": "^5.52.1",
+    "webpack": "^5.56.0",
     "webpack-cli": "^4.8.0",
     "webpack-version-file-plugin": "^0.4.0"
   },
@@ -129,10 +129,10 @@
     "@types/benchmark": "^2.1.1",
     "@types/compression": "1.7.2",
     "@types/express": "^4.17.13",
-    "@types/jest": "^27.0.1",
-    "@types/node": "^16.9.1",
+    "@types/jest": "^27.0.2",
+    "@types/node": "^16.10.2",
     "@types/node-fetch": "^2.5.12",
-    "@types/react": "^17.0.20",
+    "@types/react": "^17.0.27",
     "@types/react-dom": "^17.0.9",
     "@types/swagger-ui-dist": "3.30.1",
     "argparse": "^2.0.1",
@@ -146,8 +146,8 @@
     "node-fetch": "^2.6.2",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
-    "rxjs": "^7.3.0",
-    "swagger-ui-dist": "^3.52.1",
+    "rxjs": "^7.3.1",
+    "swagger-ui-dist": "^3.52.3",
     "tslib": "^2.3.1",
     "util.promisify": "^1.1.1",
     "xhr2": "^0.2.1"

+ 10 - 0
src/apps/viewer/index.html

@@ -52,12 +52,22 @@
             var collapseLeftPanel = getParam('collapse-left-panel', '[^&]+').trim() === '1';
             var pdbProvider = getParam('pdb-provider', '[^&]+').trim().toLowerCase();
             var emdbProvider = getParam('emdb-provider', '[^&]+').trim().toLowerCase();
+            var mapProvider = getParam('map-provider', '[^&]+').trim().toLowerCase();
+            var pixelScale = getParam('pixel-scale', '[^&]+').trim();
+            var pickScale = getParam('pick-scale', '[^&]+').trim();
+            var pickPadding = getParam('pick-padding', '[^&]+').trim();
             var viewer = new molstar.Viewer('app', {
                 layoutShowControls: !hideControls,
                 viewportShowExpand: false,
                 collapseLeftPanel: collapseLeftPanel,
                 pdbProvider: pdbProvider || 'pdbe',
                 emdbProvider: emdbProvider || 'pdbe',
+                volumeStreamingServer: (mapProvider || 'pdbe') === 'rcsb'
+                    ? 'https://maps.rcsb.org'
+                    : 'https://www.ebi.ac.uk/pdbe/densities',
+                pixelScale: parseFloat(pixelScale) || 1,
+                pickScale: parseFloat(pickScale) || 0.25,
+                pickPadding: isNaN(parseFloat(pickPadding)) ? 1 : parseFloat(pickPadding),
             });
 
             var snapshotId = getParam('snapshot-id', '[^&]+').trim();

+ 8 - 4
src/apps/viewer/index.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 David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -71,9 +71,11 @@ const DefaultViewerOptions = {
     layoutShowLog: true,
     layoutShowLeftPanel: true,
     collapseLeftPanel: false,
-    disableAntialiasing: false,
-    pixelScale: 1,
-    enableWboit: true,
+    disableAntialiasing: PluginConfig.General.DisableAntialiasing.defaultValue,
+    pixelScale: PluginConfig.General.PixelScale.defaultValue,
+    pickScale: PluginConfig.General.PickScale.defaultValue,
+    pickPadding: PluginConfig.General.PickPadding.defaultValue,
+    enableWboit: PluginConfig.General.EnableWboit.defaultValue,
 
     viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
     viewportShowControls: PluginConfig.Viewport.ShowControls.defaultValue,
@@ -130,6 +132,8 @@ export class Viewer {
             config: [
                 [PluginConfig.General.DisableAntialiasing, o.disableAntialiasing],
                 [PluginConfig.General.PixelScale, o.pixelScale],
+                [PluginConfig.General.PickScale, o.pickScale],
+                [PluginConfig.General.PickPadding, o.pickPadding],
                 [PluginConfig.General.EnableWboit, o.enableWboit],
                 [PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
                 [PluginConfig.Viewport.ShowControls, o.viewportShowControls],

+ 25 - 3
src/mol-canvas3d/camera.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 David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -27,6 +27,10 @@ interface ICamera {
     readonly fogNear: number,
 }
 
+const tmpPos1 = Vec3();
+const tmpPos2 = Vec3();
+const tmpClip = Vec4();
+
 class Camera implements ICamera {
     readonly view: Mat4 = Mat4.identity();
     readonly projection: Mat4 = Mat4.identity();
@@ -155,14 +159,32 @@ class Camera implements ICamera {
         }
     }
 
+    /** Transform point into 2D window coordinates. */
     project(out: Vec4, point: Vec3) {
         return cameraProject(out, point, this.viewport, this.projectionView);
     }
 
-    unproject(out: Vec3, point: Vec3) {
+    /**
+     * Transform point from screen space to 3D coordinates.
+     * The point must have `x` and `y` set to 2D window coordinates
+     * and `z` between 0 (near) and 1 (far); the optional `w` is not used.
+     */
+    unproject(out: Vec3, point: Vec3 | Vec4) {
         return cameraUnproject(out, point, this.viewport, this.inverseProjectionView);
     }
 
+    /** World space pixel size at given `point` */
+    getPixelSize(point: Vec3) {
+        // project -> unproject of `point` does not exactly return the same
+        // to get a sufficiently accurate measure we unproject the original
+        // clip position in addition to the one shifted bey one pixel
+        this.project(tmpClip, point);
+        this.unproject(tmpPos1, tmpClip);
+        tmpClip[0] += 1;
+        this.unproject(tmpPos2, tmpClip);
+        return Vec3.distance(tmpPos1, tmpPos2);
+    }
+
     constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128), props: Partial<{ pixelScale: number }> = {}) {
         this.viewport = viewport;
         this.pixelScale = props.pixelScale || 1;
@@ -178,7 +200,7 @@ namespace Camera {
     /**
      * Sets an offseted view in a larger frustum. This is useful for
      * - multi-window or multi-monitor/multi-machine setups
-     * - jittering the camera position for
+     * - jittering the camera position for sampling
      */
     export interface ViewOffset {
         enabled: boolean,

+ 16 - 18
src/mol-canvas3d/camera/util.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2019 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>
  */
@@ -55,14 +55,11 @@ namespace Viewport {
 
 //
 
-const NEAR_RANGE = 0;
-const FAR_RANGE = 1;
-
 const tmpVec4 = Vec4();
 
 /** Transform point into 2D window coordinates. */
 export function cameraProject(out: Vec4, point: Vec3, viewport: Viewport, projectionView: Mat4) {
-    const { x: vX, y: vY, width: vWidth, height: vHeight } = viewport;
+    const { x, y, width, height } = viewport;
 
     // clip space -> NDC -> window coordinates, implicit 1.0 for w component
     Vec4.set(tmpVec4, point[0], point[1], point[2], 1.0);
@@ -78,27 +75,28 @@ export function cameraProject(out: Vec4, point: Vec3, viewport: Viewport, projec
         tmpVec4[2] /= w;
     }
 
-    // transform into window coordinates, set fourth component is (1/clip.w) as in gl_FragCoord.w
-    out[0] = vX + vWidth / 2 * tmpVec4[0] + (0 + vWidth / 2);
-    out[1] = vY + vHeight / 2 * tmpVec4[1] + (0 + vHeight / 2);
-    out[2] = (FAR_RANGE - NEAR_RANGE) / 2 * tmpVec4[2] + (FAR_RANGE + NEAR_RANGE) / 2;
+    // transform into window coordinates, set fourth component to 1 / clip.w as in gl_FragCoord.w
+    out[0] = (tmpVec4[0] + 1) * width * 0.5 + x;
+    out[1] = (1 - tmpVec4[1]) * height * 0.5 + y; // flip Y
+    out[2] = (tmpVec4[2] + 1) * 0.5;
     out[3] = w === 0 ? 0 : 1 / w;
     return out;
 }
 
 /**
  * Transform point from screen space to 3D coordinates.
- * The point must have x and y set to 2D window coordinates and z between 0 (near) and 1 (far).
+ * The point must have `x` and `y` set to 2D window coordinates
+ * and `z` between 0 (near) and 1 (far); the optional `w` is not used.
  */
-export function cameraUnproject(out: Vec3, point: Vec3, viewport: Viewport, inverseProjectionView: Mat4) {
-    const { x: vX, y: vY, width: vWidth, height: vHeight } = viewport;
+export function cameraUnproject(out: Vec3, point: Vec3 | Vec4, viewport: Viewport, inverseProjectionView: Mat4) {
+    const { x, y, width, height } = viewport;
 
-    const x = point[0] - vX;
-    const y = (vHeight - point[1] - 1) - vY;
-    const z = point[2];
+    const px = point[0] - x;
+    const py = (height - point[1] - 1) - y;
+    const pz = point[2];
 
-    out[0] = (2 * x) / vWidth - 1;
-    out[1] = (2 * y) / vHeight - 1;
-    out[2] = 2 * z - 1;
+    out[0] = (2 * px) / width - 1;
+    out[1] = (2 * py) / height - 1;
+    out[2] = 2 * pz - 1;
     return Vec3.transformMat4(out, out, inverseProjectionView);
 }

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

@@ -23,7 +23,7 @@ import { Camera } from './camera';
 import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { DebugHelperParams } from './helper/bounding-sphere-helper';
 import { SetUtils } from '../mol-util/set';
-import { Canvas3dInteractionHelper } from './helper/interaction-events';
+import { Canvas3dInteractionHelper, Canvas3dInteractionHelperParams } from './helper/interaction-events';
 import { PostprocessingParams } from './passes/postprocessing';
 import { MultiSampleHelper, MultiSampleParams, MultiSamplePass } from './passes/multi-sample';
 import { PickData } from './passes/pick';
@@ -84,6 +84,7 @@ export const Canvas3DParams = {
     marking: PD.Group(MarkingParams),
     renderer: PD.Group(RendererParams),
     trackball: PD.Group(TrackballControlsParams),
+    interaction: PD.Group(Canvas3dInteractionHelperParams),
     debug: PD.Group(DebugHelperParams),
     handle: PD.Group(HandleHelperParams),
 };
@@ -115,19 +116,23 @@ namespace Canvas3DContext {
         preserveDrawingBuffer: true,
         pixelScale: 1,
         pickScale: 0.25,
-        enableWboit: true
+        /** extra pixels to around target to check in case target is empty */
+        pickPadding: 1,
+        enableWboit: true,
+        preferWebGl1: false
     };
     export type Attribs = typeof DefaultAttribs
 
     export function fromCanvas(canvas: HTMLCanvasElement, attribs: Partial<Attribs> = {}): Canvas3DContext {
         const a = { ...DefaultAttribs, ...attribs };
-        const { antialias, preserveDrawingBuffer, pixelScale } = a;
+        const { antialias, preserveDrawingBuffer, pixelScale, preferWebGl1 } = a;
         const gl = getGLContext(canvas, {
             antialias,
             preserveDrawingBuffer,
             alpha: true, // the renderer requires an alpha channel
             depth: true, // the renderer requires a depth buffer
             premultipliedAlpha: true, // the renderer outputs PMA
+            preferWebGl1
         });
         if (gl === null) throw new Error('Could not create a WebGL rendering context');
 
@@ -305,8 +310,8 @@ namespace Canvas3D {
         const renderer = Renderer.create(webgl, p.renderer);
         const helper = new Helper(webgl, scene, p);
 
-        const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height });
-        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera);
+        const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height }, attribs.pickPadding);
+        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, p.interaction);
         const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
 
         let cameraResetRequested = false;
@@ -642,6 +647,7 @@ namespace Canvas3D {
                 multiSample: { ...p.multiSample },
                 renderer: { ...renderer.props },
                 trackball: { ...controls.props },
+                interaction: { ...interactionHelper.props },
                 debug: { ...helper.debug.props },
                 handle: { ...helper.handle.props },
             };
@@ -778,6 +784,7 @@ namespace Canvas3D {
                 if (props.multiSample) Object.assign(p.multiSample, props.multiSample);
                 if (props.renderer) renderer.setProps(props.renderer);
                 if (props.trackball) controls.setProps(props.trackball);
+                if (props.interaction) interactionHelper.setProps(props.interaction);
                 if (props.debug) helper.debug.setProps(props.debug);
                 if (props.handle) helper.handle.setProps(props.handle);
 

+ 47 - 5
src/mol-canvas3d/helper/interaction-events.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 David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -11,6 +11,8 @@ import { InputObserver, ModifiersKeys, ButtonsType } from '../../mol-util/input/
 import { RxEventHelper } from '../../mol-util/rx-event-helper';
 import { Vec2, Vec3 } from '../../mol-math/linear-algebra';
 import { Camera } from '../camera';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { Bond } from '../../mol-model/structure';
 
 type Canvas3D = import('../canvas3d').Canvas3D
 type HoverEvent = import('../canvas3d').Canvas3D.HoverEvent
@@ -19,6 +21,17 @@ type ClickEvent = import('../canvas3d').Canvas3D.ClickEvent
 
 const enum InputEvent { Move, Click, Drag }
 
+const tmpPosA = Vec3();
+const tmpPos = Vec3();
+const tmpNorm = Vec3();
+
+export const Canvas3dInteractionHelperParams = {
+    maxFps: PD.Numeric(30, { min: 10, max: 60, step: 10 }),
+    preferAtomPixelPadding: PD.Numeric(3, { min: 0, max: 20, step: 1 }, { description: 'Number of extra pixels at which to prefer atoms over bonds.' }),
+};
+export type Canvas3dInteractionHelperParams = typeof Canvas3dInteractionHelperParams
+export type Canvas3dInteractionHelperProps = PD.Values<Canvas3dInteractionHelperParams>
+
 export class Canvas3dInteractionHelper {
     private ev = RxEventHelper.create();
 
@@ -48,6 +61,12 @@ export class Canvas3dInteractionHelper {
     private button: ButtonsType.Flag = ButtonsType.create(0);
     private modifiers: ModifiersKeys = ModifiersKeys.None;
 
+    readonly props: Canvas3dInteractionHelperProps;
+
+    setProps(props: Partial<Canvas3dInteractionHelperProps>) {
+        Object.assign(this.props, props);
+    }
+
     private identify(e: InputEvent, t: number) {
         const xyChanged = this.startX !== this.endX || this.startY !== this.endY;
 
@@ -70,7 +89,7 @@ export class Canvas3dInteractionHelper {
         }
 
         if (e === InputEvent.Click) {
-            const loci = this.getLoci(this.id);
+            const loci = this.getLoci(this.id, this.position);
             this.events.click.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
             this.prevLoci = loci;
             return;
@@ -78,13 +97,13 @@ export class Canvas3dInteractionHelper {
 
         if (!this.inside || this.currentIdentifyT !== t || !xyChanged || this.outsideViewport(this.endX, this.endY)) return;
 
-        const loci = this.getLoci(this.id);
+        const loci = this.getLoci(this.id, this.position);
         this.events.hover.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
         this.prevLoci = loci;
     }
 
     tick(t: number) {
-        if (this.inside && t - this.prevT > 1000 / this.maxFps) {
+        if (this.inside && t - this.prevT > 1000 / this.props.maxFps) {
             this.prevT = t;
             this.currentIdentifyT = t;
             this.identify(this.isInteracting ? InputEvent.Drag : InputEvent.Move, t);
@@ -144,11 +163,34 @@ export class Canvas3dInteractionHelper {
         );
     }
 
+    private getLoci(pickingId: PickingId | undefined, position: Vec3 | undefined) {
+        const { repr, loci } = this.lociGetter(pickingId);
+        if (position && repr && Bond.isLoci(loci) && loci.bonds.length === 2) {
+            const { aUnit, aIndex } = loci.bonds[0];
+            aUnit.conformation.position(aUnit.elements[aIndex], tmpPosA);
+            Vec3.sub(tmpNorm, this.camera.state.position, this.camera.state.target);
+            Vec3.projectPointOnPlane(tmpPos, position, tmpNorm, tmpPosA);
+            const pixelSize = this.camera.getPixelSize(tmpPos);
+            let radius = repr.theme.size.size(loci.bonds[0]) * (repr.props.sizeFactor ?? 1);
+            if (repr.props.lineSizeAttenuation === false) {
+                // divide by two to get radius
+                radius *= pixelSize / 2;
+            }
+            radius += this.props.preferAtomPixelPadding * pixelSize;
+            if (Vec3.distance(tmpPos, tmpPosA) < radius) {
+                return { repr, loci: Bond.toFirstStructureElementLoci(loci) };
+            }
+        }
+        return { repr, loci };
+    }
+
     dispose() {
         this.ev.dispose();
     }
 
-    constructor(private canvasIdentify: Canvas3D['identify'], private getLoci: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private maxFps: number = 30) {
+    constructor(private canvasIdentify: Canvas3D['identify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, props: Partial<Canvas3dInteractionHelperProps> = {}) {
+        this.props = { ...PD.getDefaultValues(Canvas3dInteractionHelperParams), ...props };
+
         input.drag.subscribe(({ x, y, buttons, button, modifiers }) => {
             this.isInteracting = true;
             // console.log('drag');

+ 1 - 0
src/mol-canvas3d/passes/draw.ts

@@ -362,6 +362,7 @@ export class DrawPass {
     render(renderer: Renderer, camera: Camera | StereoCamera, scene: Scene, helper: Helper, toDrawingBuffer: boolean, transparentBackground: boolean, postprocessingProps: PostprocessingProps, markingProps: MarkingProps) {
         renderer.setTransparentBackground(transparentBackground);
         renderer.setDrawingBufferSize(this.colorTarget.getWidth(), this.colorTarget.getHeight());
+        renderer.setPixelRatio(this.webgl.pixelRatio);
 
         if (StereoCamera.is(camera)) {
             this._render(renderer, camera.left, scene, helper, toDrawingBuffer, transparentBackground, postprocessingProps, markingProps);

+ 17 - 3
src/mol-canvas3d/passes/pick.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>
  */
@@ -11,6 +11,7 @@ import { WebGLContext } from '../../mol-gl/webgl/context';
 import { GraphicsRenderVariant } from '../../mol-gl/webgl/render-item';
 import { RenderTarget } from '../../mol-gl/webgl/render-target';
 import { Vec3 } from '../../mol-math/linear-algebra';
+import { spiral2d } from '../../mol-math/misc';
 import { decodeFloatRGB, unpackRGBAToDepth } from '../../mol-util/float-packing';
 import { Camera, ICamera } from '../camera';
 import { StereoCamera } from '../camera/stereo';
@@ -88,6 +89,7 @@ export class PickPass {
 
         this.groupPickTarget.bind();
         this.renderVariant(renderer, camera, scene, helper, 'pickGroup');
+        // printTexture(this.webgl, this.groupPickTarget.texture, { id: 'group' })
 
         this.depthPickTarget.bind();
         this.renderVariant(renderer, camera, scene, helper, 'depth');
@@ -111,6 +113,8 @@ export class PickHelper {
     private pickHeight: number
     private halfPickWidth: number
 
+    private spiral: [number, number][]
+
     private setupBuffers() {
         const bufferSize = this.pickWidth * this.pickHeight * 4;
         if (!this.objectBuffer || this.objectBuffer.length !== bufferSize) {
@@ -138,6 +142,8 @@ export class PickHelper {
 
             this.setupBuffers();
         }
+
+        this.spiral = spiral2d(Math.round(this.pickScale * this.pickPadding));
     }
 
     private syncBuffers() {
@@ -177,6 +183,7 @@ export class PickHelper {
 
         renderer.setTransparentBackground(false);
         renderer.setDrawingBufferSize(this.pickPass.objectPickTarget.getWidth(), this.pickPass.objectPickTarget.getHeight());
+        renderer.setPixelRatio(this.pickScale);
 
         if (StereoCamera.is(camera)) {
             renderer.setViewport(pickX, pickY, halfPickWidth, pickHeight);
@@ -192,7 +199,7 @@ export class PickHelper {
         this.dirty = false;
     }
 
-    identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
+    private identifyInternal(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
         const { webgl, pickScale } = this;
         if (webgl.isContextLost) return;
 
@@ -251,7 +258,14 @@ export class PickHelper {
         return { id: { objectId, instanceId, groupId }, position };
     }
 
-    constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport) {
+    identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
+        for (const d of this.spiral) {
+            const pickData = this.identifyInternal(x + d[0], y + d[1], camera);
+            if (pickData) return pickData;
+        }
+    }
+
+    constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport, readonly pickPadding = 1) {
         this.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
     }
 }

+ 10 - 2
src/mol-geo/geometry/direct-volume/direct-volume.ts

@@ -9,7 +9,7 @@ import { LocationIterator, PositionLocation } from '../../../mol-geo/util/locati
 import { RenderableState } from '../../../mol-gl/renderable';
 import { DirectVolumeValues } from '../../../mol-gl/renderable/direct-volume';
 import { calculateTransformBoundingSphere } from '../../../mol-gl/renderable/util';
-import { Texture } from '../../../mol-gl/webgl/texture';
+import { createNullTexture, Texture } from '../../../mol-gl/webgl/texture';
 import { Box3D, Sphere3D } from '../../../mol-math/geometry';
 import { Mat4, Vec2, Vec3, Vec4 } from '../../../mol-math/linear-algebra';
 import { Theme } from '../../../mol-theme/theme';
@@ -129,7 +129,15 @@ export namespace DirectVolume {
     }
 
     export function createEmpty(directVolume?: DirectVolume): DirectVolume {
-        return {} as DirectVolume; // TODO
+        const bbox = Box3D();
+        const gridDimension = Vec3();
+        const transform = Mat4.identity();
+        const unitToCartn = Mat4.identity();
+        const cellDim = Vec3();
+        const texture = createNullTexture();
+        const stats = Grid.One.stats;
+        const packedGroup = false;
+        return create(bbox, gridDimension, transform, unitToCartn, cellDim, texture, stats, packedGroup, directVolume);
     }
 
     export function createRenderModeParam(stats?: Grid['stats']) {

+ 5 - 2
src/mol-geo/geometry/image/image.ts

@@ -7,7 +7,7 @@
 import { hashFnv32a } from '../../../mol-data/util';
 import { LocationIterator } from '../../../mol-geo/util/location-iterator';
 import { RenderableState } from '../../../mol-gl/renderable';
-import { calculateTransformBoundingSphere, TextureImage } from '../../../mol-gl/renderable/util';
+import { calculateTransformBoundingSphere, createTextureImage, TextureImage } from '../../../mol-gl/renderable/util';
 import { Sphere3D } from '../../../mol-math/geometry';
 import { Vec2, Vec4, Vec3 } from '../../../mol-math/linear-algebra';
 import { Theme } from '../../../mol-theme/theme';
@@ -113,7 +113,10 @@ namespace Image {
     }
 
     export function createEmpty(image?: Image): Image {
-        return {} as Image; // TODO
+        const imageTexture = createTextureImage(0, 4, Uint8Array);
+        const corners = image ? image.cornerBuffer.ref.value : new Float32Array(8 * 3);
+        const groupTexture = createTextureImage(0, 4, Uint8Array);
+        return create(imageTexture, corners, groupTexture, image);
     }
 
     export const Params = {

+ 1 - 1
src/mol-geo/geometry/lines/lines.ts

@@ -164,7 +164,7 @@ export namespace Lines {
 
     export const Params = {
         ...BaseGeometry.Params,
-        sizeFactor: PD.Numeric(1.5, { min: 0, max: 10, step: 0.1 }),
+        sizeFactor: PD.Numeric(3, { min: 0, max: 10, step: 0.1 }),
         lineSizeAttenuation: PD.Boolean(false),
     };
     export type Params = typeof Params

+ 14 - 12
src/mol-geo/geometry/points/points.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>
  */
@@ -117,12 +117,19 @@ export namespace Points {
 
     //
 
+    export const StyleTypes = {
+        'square': 'Square',
+        'circle': 'Circle',
+        'fuzzy': 'Fuzzy',
+    };
+    export type StyleTypes = keyof typeof StyleTypes;
+    export const StyleTypeNames = Object.keys(StyleTypes) as StyleTypes[];
+
     export const Params = {
         ...BaseGeometry.Params,
-        sizeFactor: PD.Numeric(1.5, { min: 0, max: 10, step: 0.1 }),
+        sizeFactor: PD.Numeric(3, { min: 0, max: 10, step: 0.1 }),
         pointSizeAttenuation: PD.Boolean(false),
-        pointFilledCircle: PD.Boolean(false),
-        pointEdgeBleach: PD.Numeric(0.2, { min: 0, max: 1, step: 0.05 }),
+        pointStyle: PD.Select('square', PD.objectToOptions(StyleTypes)),
     };
     export type Params = typeof Params
 
@@ -189,8 +196,7 @@ export namespace Points {
             ...BaseGeometry.createValues(props, counts),
             uSizeFactor: ValueCell.create(props.sizeFactor),
             dPointSizeAttenuation: ValueCell.create(props.pointSizeAttenuation),
-            dPointFilledCircle: ValueCell.create(props.pointFilledCircle),
-            uPointEdgeBleach: ValueCell.create(props.pointEdgeBleach),
+            dPointStyle: ValueCell.create(props.pointStyle),
         };
     }
 
@@ -204,8 +210,7 @@ export namespace Points {
         BaseGeometry.updateValues(values, props);
         ValueCell.updateIfChanged(values.uSizeFactor, props.sizeFactor);
         ValueCell.updateIfChanged(values.dPointSizeAttenuation, props.pointSizeAttenuation);
-        ValueCell.updateIfChanged(values.dPointFilledCircle, props.pointFilledCircle);
-        ValueCell.updateIfChanged(values.uPointEdgeBleach, props.pointEdgeBleach);
+        ValueCell.updateIfChanged(values.dPointStyle, props.pointStyle);
     }
 
     function updateBoundingSphere(values: PointsValues, points: Points) {
@@ -229,10 +234,7 @@ export namespace Points {
 
     function updateRenderableState(state: RenderableState, props: PD.Values<Params>) {
         BaseGeometry.updateRenderableState(state, props);
-        state.opaque = state.opaque && (
-            !props.pointFilledCircle ||
-            (props.pointFilledCircle && props.pointEdgeBleach === 0)
-        );
+        state.opaque = state.opaque && props.pointStyle !== 'fuzzy';
         state.writeDepth = state.opaque;
     }
 }

+ 6 - 2
src/mol-geo/geometry/texture-mesh/texture-mesh.ts

@@ -19,7 +19,7 @@ import { createEmptyOverpaint } from '../overpaint-data';
 import { createEmptyTransparency } from '../transparency-data';
 import { TextureMeshValues } from '../../../mol-gl/renderable/texture-mesh';
 import { calculateTransformBoundingSphere } from '../../../mol-gl/renderable/util';
-import { Texture } from '../../../mol-gl/webgl/texture';
+import { createNullTexture, Texture } from '../../../mol-gl/webgl/texture';
 import { Vec2, Vec4 } from '../../../mol-math/linear-algebra';
 import { createEmptyClipping } from '../clipping-data';
 import { NullLocation } from '../../../mol-model/location';
@@ -97,7 +97,11 @@ export namespace TextureMesh {
     }
 
     export function createEmpty(textureMesh?: TextureMesh): TextureMesh {
-        return {} as TextureMesh; // TODO
+        const vt = textureMesh ? textureMesh.vertexTexture.ref.value : createNullTexture();
+        const gt = textureMesh ? textureMesh.groupTexture.ref.value : createNullTexture();
+        const nt = textureMesh ? textureMesh.normalTexture.ref.value : createNullTexture();
+        const bs = textureMesh ? textureMesh.boundingSphere : Sphere3D();
+        return create(0, 0, vt, gt, nt, bs, textureMesh);
     }
 
     export const Params = {

+ 1 - 2
src/mol-gl/_spec/renderer.spec.ts

@@ -85,8 +85,7 @@ function createPoints() {
 
         uSizeFactor: ValueCell.create(1),
         dPointSizeAttenuation: ValueCell.create(true),
-        dPointFilledCircle: ValueCell.create(false),
-        uPointEdgeBleach: ValueCell.create(0.5),
+        dPointStyle: ValueCell.create('square'),
     };
     const state: RenderableState = {
         disposed: false,

+ 4 - 4
src/mol-gl/renderable/points.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018 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>
  */
@@ -7,9 +7,10 @@
 import { Renderable, RenderableState, createRenderable } from '../renderable';
 import { WebGLContext } from '../webgl/context';
 import { createGraphicsRenderItem } from '../webgl/render-item';
-import { GlobalUniformSchema, BaseSchema, AttributeSpec, UniformSpec, DefineSpec, Values, InternalSchema, SizeSchema, InternalValues, GlobalTextureSchema } from './schema';
+import { GlobalUniformSchema, BaseSchema, AttributeSpec, DefineSpec, Values, InternalSchema, SizeSchema, InternalValues, GlobalTextureSchema } from './schema';
 import { PointsShaderCode } from '../shader-code';
 import { ValueCell } from '../../mol-util';
+import { Points } from '../../mol-geo/geometry/points/points';
 
 export const PointsSchema = {
     ...BaseSchema,
@@ -17,8 +18,7 @@ export const PointsSchema = {
     aGroup: AttributeSpec('float32', 1, 0),
     aPosition: AttributeSpec('float32', 3, 0),
     dPointSizeAttenuation: DefineSpec('boolean'),
-    dPointFilledCircle: DefineSpec('boolean'),
-    uPointEdgeBleach: UniformSpec('f'),
+    dPointStyle: DefineSpec('string', Points.StyleTypeNames),
 };
 export type PointsSchema = typeof PointsSchema
 export type PointsValues = Values<PointsSchema>

+ 1 - 1
src/mol-gl/renderable/schema.ts

@@ -121,7 +121,6 @@ export const GlobalUniformSchema = {
 
     uIsOrtho: UniformSpec('f'),
     uPixelRatio: UniformSpec('f'),
-    uViewportHeight: UniformSpec('f'),
     uViewport: UniformSpec('v4'),
     uViewOffset: UniformSpec('v2'),
     uDrawingBufferSize: UniformSpec('v2'),
@@ -162,6 +161,7 @@ export const GlobalUniformSchema = {
     uSelectColor: UniformSpec('v3'),
     uHighlightStrength: UniformSpec('f'),
     uSelectStrength: UniformSpec('f'),
+    uMarkerPriority: UniformSpec('i'),
 
     uXrayEdgeFalloff: UniformSpec('f'),
 

+ 12 - 4
src/mol-gl/renderer.ts

@@ -62,6 +62,7 @@ interface Renderer {
     setViewport: (x: number, y: number, width: number, height: number) => void
     setTransparentBackground: (value: boolean) => void
     setDrawingBufferSize: (width: number, height: number) => void
+    setPixelRatio: (value: number) => void
 
     dispose: () => void
 }
@@ -80,6 +81,7 @@ export const RendererParams = {
     selectColor: PD.Color(Color.fromNormalizedRgb(0.2, 1.0, 0.1)),
     highlightStrength: PD.Numeric(0.7, { min: 0.0, max: 1.0, step: 0.1 }),
     selectStrength: PD.Numeric(0.7, { min: 0.0, max: 1.0, step: 0.1 }),
+    markerPriority: PD.Select(1, [[1, 'Highlight'], [2, 'Select']]),
 
     xrayEdgeFalloff: PD.Numeric(1, { min: 0.0, max: 3.0, step: 0.1 }),
 
@@ -233,7 +235,6 @@ namespace Renderer {
             uViewOffset: ValueCell.create(viewOffset),
 
             uPixelRatio: ValueCell.create(ctx.pixelRatio),
-            uViewportHeight: ValueCell.create(viewport.height),
             uViewport: ValueCell.create(Viewport.toVec4(Vec4(), viewport)),
             uDrawingBufferSize: ValueCell.create(drawingBufferSize),
 
@@ -274,6 +275,7 @@ namespace Renderer {
             uSelectColor: ValueCell.create(Color.toVec3Normalized(Vec3(), p.selectColor)),
             uHighlightStrength: ValueCell.create(p.highlightStrength),
             uSelectStrength: ValueCell.create(p.selectStrength),
+            uMarkerPriority: ValueCell.create(p.markerPriority),
 
             uXrayEdgeFalloff: ValueCell.create(p.xrayEdgeFalloff),
         };
@@ -571,7 +573,7 @@ namespace Renderer {
                 // TODO: simplify, handle in renderable.state???
                 // uAlpha is updated in "render" so we need to recompute it here
                 const alpha = clamp(r.values.alpha.ref.value * r.state.alphaFactor, 0, 1);
-                if (alpha === 1 && r.values.transparencyAverage.ref.value !== 1 && r.values.dRenderMode?.ref.value !== 'volume' && !r.values.dPointFilledCircle?.ref.value && !r.values.dXrayShaded?.ref.value) {
+                if (alpha === 1 && r.values.transparencyAverage.ref.value !== 1 && r.values.dRenderMode?.ref.value !== 'volume' && r.values.dPointStyle?.ref.value !== 'fuzzy' && !r.values.dXrayShaded?.ref.value) {
                     renderObject(r, 'colorWboit');
                 }
             }
@@ -587,7 +589,7 @@ namespace Renderer {
                 // TODO: simplify, handle in renderable.state???
                 // uAlpha is updated in "render" so we need to recompute it here
                 const alpha = clamp(r.values.alpha.ref.value * r.state.alphaFactor, 0, 1);
-                if (alpha < 1 || r.values.transparencyAverage.ref.value > 0 || r.values.dRenderMode?.ref.value === 'volume' || r.values.dPointFilledCircle?.ref.value || !!r.values.uBackgroundColor || r.values.dXrayShaded?.ref.value) {
+                if (alpha < 1 || r.values.transparencyAverage.ref.value > 0 || r.values.dRenderMode?.ref.value === 'volume' || r.values.dPointStyle?.ref.value === 'fuzzy' || !!r.values.uBackgroundColor || r.values.dXrayShaded?.ref.value) {
                     renderObject(r, 'colorWboit');
                 }
             }
@@ -670,6 +672,10 @@ namespace Renderer {
                     p.selectStrength = props.selectStrength;
                     ValueCell.update(globalUniforms.uSelectStrength, p.selectStrength);
                 }
+                if (props.markerPriority !== undefined && props.markerPriority !== p.markerPriority) {
+                    p.markerPriority = props.markerPriority;
+                    ValueCell.update(globalUniforms.uMarkerPriority, p.markerPriority);
+                }
 
                 if (props.xrayEdgeFalloff !== undefined && props.xrayEdgeFalloff !== p.xrayEdgeFalloff) {
                     p.xrayEdgeFalloff = props.xrayEdgeFalloff;
@@ -700,7 +706,6 @@ namespace Renderer {
                 gl.scissor(x, y, width, height);
                 if (x !== viewport.x || y !== viewport.y || width !== viewport.width || height !== viewport.height) {
                     Viewport.set(viewport, x, y, width, height);
-                    ValueCell.update(globalUniforms.uViewportHeight, height);
                     ValueCell.update(globalUniforms.uViewport, Vec4.set(globalUniforms.uViewport.ref.value, x, y, width, height));
                 }
             },
@@ -712,6 +717,9 @@ namespace Renderer {
                     ValueCell.update(globalUniforms.uDrawingBufferSize, Vec2.set(drawingBufferSize, width, height));
                 }
             },
+            setPixelRatio: (value: number) => {
+                ValueCell.update(globalUniforms.uPixelRatio, value);
+            },
 
             props: p,
             get stats(): RendererStats {

+ 2 - 2
src/mol-gl/shader/chunks/apply-marker-color.glsl.ts

@@ -1,6 +1,6 @@
 export const apply_marker_color = `
-if (marker > 0.1) {
-    if (intMod(marker, 2.0) > 0.1) {
+if (marker > 0.0) {
+    if ((uMarkerPriority == 1 && marker != 2.0) || (uMarkerPriority != 1 && marker == 1.0)) {
         gl_FragColor.rgb = mix(gl_FragColor.rgb, uHighlightColor, uHighlightStrength);
         gl_FragColor.a = max(gl_FragColor.a, uHighlightStrength * 0.002); // for direct-volume rendering
     } else {

+ 1 - 2
src/mol-gl/shader/chunks/assign-material-color.glsl.ts

@@ -3,9 +3,8 @@ export const assign_material_color = `
     #if defined(dMarkerType_uniform)
         float marker = uMarker;
     #elif defined(dMarkerType_groupInstance)
-        float marker = vMarker;
+        float marker = floor(vMarker * 255.0 + 0.5); // rounding required to work on some cards on win
     #endif
-    marker = floor(marker * 255.0 + 0.5); // rounding required to work on some cards on win
 #endif
 
 #if defined(dRenderVariant_color)

+ 1 - 0
src/mol-gl/shader/chunks/common-frag-params.glsl.ts

@@ -23,6 +23,7 @@ uniform vec3 uHighlightColor;
 uniform vec3 uSelectColor;
 uniform float uHighlightStrength;
 uniform float uSelectStrength;
+uniform int uMarkerPriority;
 
 #if defined(dMarkerType_uniform)
     uniform float uMarker;

+ 1 - 0
src/mol-gl/shader/direct-volume.frag.ts

@@ -55,6 +55,7 @@ uniform vec3 uHighlightColor;
 uniform vec3 uSelectColor;
 uniform float uHighlightStrength;
 uniform float uSelectStrength;
+uniform int uMarkerPriority;
 
 #if defined(dMarkerType_uniform)
     uniform float uMarker;

+ 10 - 7
src/mol-gl/shader/lines.vert.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>
  *
@@ -18,7 +18,7 @@ precision highp int;
 #include common_clip
 
 uniform float uPixelRatio;
-uniform float uViewportHeight;
+uniform vec4 uViewport;
 
 attribute mat4 aTransform;
 attribute float aInstance;
@@ -39,6 +39,8 @@ void trimSegment(const in vec4 start, inout vec4 end) {
 }
 
 void main(){
+    float aspect = uViewport.z / uViewport.w;
+
     #include assign_group
     #include assign_color_varying
     #include assign_marker_varying
@@ -83,15 +85,15 @@ void main(){
     vec2 dir = ndcEnd - ndcStart;
 
     // account for clip-space aspect ratio
-    dir.x *= uPixelRatio;
+    dir.x *= aspect;
     dir = normalize(dir);
 
     // perpendicular to dir
     vec2 offset = vec2(dir.y, - dir.x);
 
     // undo aspect ratio adjustment
-    dir.x /= uPixelRatio;
-    offset.x /= uPixelRatio;
+    dir.x /= aspect;
+    offset.x /= aspect;
 
     // sign flip
     if (aMapping.x < 0.0) offset *= -1.0;
@@ -99,16 +101,17 @@ void main(){
     // calculate linewidth
     float linewidth;
     #ifdef dLineSizeAttenuation
-        linewidth = size * uPixelRatio * ((uViewportHeight / 2.0) / -start.z) * 5.0;
+        linewidth = size * uPixelRatio * ((uViewport.w / 2.0) / -start.z) * 5.0;
     #else
         linewidth = size * uPixelRatio;
     #endif
+    linewidth = max(1.0, linewidth);
 
     // adjust for linewidth
     offset *= linewidth;
 
     // adjust for clip-space to screen-space conversion
-    offset /= uViewportHeight;
+    offset /= uViewport.w;
 
     // select end
     vec4 clip = (aMapping.y < 0.5) ? clipStart : clipEnd;

+ 11 - 9
src/mol-gl/shader/points.frag.ts

@@ -13,10 +13,6 @@ precision highp int;
 #include color_frag_params
 #include common_clip
 
-#ifdef dPointFilledCircle
-    uniform float uPointEdgeBleach;
-#endif
-
 const vec2 center = vec2(0.5);
 const float radius = 0.5;
 
@@ -27,6 +23,15 @@ void main(){
     bool interior = false;
     #include assign_material_color
 
+    #if defined(dPointStyle_circle)
+        float dist = distance(gl_PointCoord, center);
+        if (dist > radius) discard;
+    #elif defined(dPointStyle_fuzzy)
+        float dist = distance(gl_PointCoord, center);
+        float fuzzyAlpha = 1.0 - smoothstep(0.0, radius, dist);
+        if (fuzzyAlpha < 0.0001) discard;
+    #endif
+
     #if defined(dRenderVariant_pick)
         #include check_picking_alpha
         gl_FragColor = material;
@@ -37,11 +42,8 @@ void main(){
     #elif defined(dRenderVariant_color)
         gl_FragColor = material;
 
-        #ifdef dPointFilledCircle
-            float dist = distance(gl_PointCoord, center);
-            float alpha = 1.0 - smoothstep(radius - uPointEdgeBleach, radius, dist);
-            if (alpha < 0.0001) discard;
-            gl_FragColor.a *= alpha;
+        #if defined(dPointStyle_fuzzy)
+            gl_FragColor.a *= fuzzyAlpha;
         #endif
 
         #include apply_marker_color

+ 4 - 3
src/mol-gl/shader/points.vert.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>
  */
@@ -16,7 +16,7 @@ precision highp int;
 #include common_clip
 
 uniform float uPixelRatio;
-uniform float uViewportHeight;
+uniform vec4 uViewport;
 
 attribute vec3 aPosition;
 attribute mat4 aTransform;
@@ -32,10 +32,11 @@ void main(){
     #include assign_size
 
     #ifdef dPointSizeAttenuation
-        gl_PointSize = size * uPixelRatio * ((uViewportHeight / 2.0) / -mvPosition.z) * 5.0;
+        gl_PointSize = size * uPixelRatio * ((uViewport.w / 2.0) / -mvPosition.z) * 5.0;
     #else
         gl_PointSize = size * uPixelRatio;
     #endif
+    gl_PointSize = max(1.0, gl_PointSize);
 
     gl_Position = uProjection * mvPosition;
 

+ 3 - 3
src/mol-gl/shader/text.vert.ts

@@ -31,7 +31,7 @@ uniform float uOffsetZ;
 
 // uniform bool ortho;
 uniform float uPixelRatio;
-uniform float uViewportHeight;
+uniform vec4 uViewport;
 
 varying vec2 vTexCoord;
 
@@ -60,9 +60,9 @@ void main(void){
     // TODO
     // #ifdef FIXED_SIZE
     //     if (ortho) {
-    //         scale /= pixelRatio * ((uViewportHeight / 2.0) / -uCameraPosition.z) * 0.1;
+    //         scale /= pixelRatio * ((uViewport.w / 2.0) / -uCameraPosition.z) * 0.1;
     //     } else {
-    //         scale /= pixelRatio * ((uViewportHeight / 2.0) / -mvPosition.z) * 0.1;
+    //         scale /= pixelRatio * ((uViewport.w / 2.0) / -mvPosition.z) * 0.1;
     //     }
     // #endif
 

+ 2 - 2
src/mol-gl/webgl/context.ts

@@ -18,7 +18,7 @@ import { now } from '../../mol-util/now';
 import { Texture, TextureFilter } from './texture';
 import { ComputeRenderable } from '../renderable';
 
-export function getGLContext(canvas: HTMLCanvasElement, attribs?: WebGLContextAttributes): GLRenderingContext | null {
+export function getGLContext(canvas: HTMLCanvasElement, attribs?: WebGLContextAttributes & { preferWebGl1?: boolean }): GLRenderingContext | null {
     function get(id: 'webgl' | 'experimental-webgl' | 'webgl2') {
         try {
             return canvas.getContext(id, attribs) as GLRenderingContext | null;
@@ -26,7 +26,7 @@ export function getGLContext(canvas: HTMLCanvasElement, attribs?: WebGLContextAt
             return null;
         }
     }
-    const gl = get('webgl2') || get('webgl') || get('experimental-webgl');
+    const gl = (attribs?.preferWebGl1 ? null : get('webgl2')) || get('webgl') || get('experimental-webgl');
     if (isDebugMode) console.log(`isWebgl2: ${isWebGL2(gl)}`);
     return gl;
 }

+ 10 - 1
src/mol-io/reader/common/text/column/token.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2017 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>
  */
 
 import { Column, ColumnHelpers } from '../../../../../mol-data/db';
@@ -50,4 +51,12 @@ export function areValuesEqualProvider(tokens: Tokens) {
         }
         return true;
     };
+}
+
+export function areTokensEmpty(tokens: Tokens) {
+    const { count, indices } = tokens;
+    for (let i = 0; i < count; ++i) {
+        if (indices[2 * i] !== indices[2 * i + 1]) return false;
+    }
+    return true;
 }

+ 9 - 2
src/mol-math/geometry/primitives/axes3d.ts

@@ -1,5 +1,5 @@
 /**
- * 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 Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -48,7 +48,7 @@ namespace Axes3D {
         return out;
     }
 
-    const tmpTransformMat3 = Mat3.zero();
+    const tmpTransformMat3 = Mat3();
     /** Transform axes with a Mat4 */
     export function transform(out: Axes3D, a: Axes3D, m: Mat4): Axes3D {
         Vec3.transformMat4(out.origin, a.origin, m);
@@ -58,6 +58,13 @@ namespace Axes3D {
         Vec3.transformMat3(out.dirC, a.dirC, n);
         return out;
     }
+
+    export function scale(out: Axes3D, a: Axes3D, scale: number): Axes3D {
+        Vec3.scale(out.dirA, a.dirA, scale);
+        Vec3.scale(out.dirB, a.dirB, scale);
+        Vec3.scale(out.dirC, a.dirC, scale);
+        return out;
+    }
 }
 
 export { Axes3D };

+ 11 - 3
src/mol-math/linear-algebra/3d/vec3.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>
@@ -540,9 +540,17 @@ namespace Vec3 {
 
     /** Project `point` onto `vector` starting from `origin` */
     export function projectPointOnVector(out: Vec3, point: Vec3, vector: Vec3, origin: Vec3) {
-        sub(out, copy(out, point), origin);
+        sub(out, point, origin);
         const scalar = dot(vector, out) / squaredMagnitude(vector);
-        return add(out, scale(out, copy(out, vector), scalar), origin);
+        return add(out, scale(out, vector, scalar), origin);
+    }
+
+    const tmpProjectPlane = zero();
+    /** Project `point` onto `plane` defined by `normal` starting from `origin` */
+    export function projectPointOnPlane(out: Vec3, point: Vec3, normal: Vec3, origin: Vec3) {
+        normalize(tmpProjectPlane, normal);
+        sub(out, point, origin);
+        return sub(out, point, scale(tmpProjectPlane, tmpProjectPlane, dot(out, tmpProjectPlane)));
     }
 
     export function projectOnVector(out: Vec3, p: Vec3, vector: Vec3) {

+ 14 - 3
src/mol-math/linear-algebra/tensor.ts

@@ -109,7 +109,10 @@ export namespace Tensor {
                 set: (t, d, x) => t[d] = x,
                 add: (t, d, x) => t[d] += x,
                 dataOffset: (d) => d,
-                getCoords: (o, c) => { c[0] = o; return c as number[]; }
+                getCoords: (o, c) => {
+                    c[0] = o;
+                    return c as number[];
+                }
             };
             case 2: {
                 // column major
@@ -120,7 +123,11 @@ export namespace Tensor {
                         set: (t, i, j, x) => t[j * rows + i] = x,
                         add: (t, i, j, x) => t[j * rows + i] += x,
                         dataOffset: (i, j) => j * rows + i,
-                        getCoords: (o, c) => { c[0] = o % rows; c[1] = Math.floor(o / rows) ; return c as number[]; }
+                        getCoords: (o, c) => {
+                            c[0] = o % rows;
+                            c[1] = Math.floor(o / rows);
+                            return c as number[];
+                        }
                     };
                 }
                 if (ao[0] === 1 && ao[1] === 0) {
@@ -130,7 +137,11 @@ export namespace Tensor {
                         set: (t, i, j, x) => t[i * cols + j] = x,
                         add: (t, i, j, x) => t[i * cols + j] += x,
                         dataOffset: (i, j) => i * cols + j,
-                        getCoords: (o, c) => { c[0] = Math.floor(o / cols); c[1] = o % cols; return c as number[]; }
+                        getCoords: (o, c) => {
+                            c[0] = Math.floor(o / cols);
+                            c[1] = o % cols;
+                            return c as number[];
+                        }
                     };
                 }
                 throw new Error('bad axis order');

+ 25 - 1
src/mol-math/misc.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2019 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>
  */
@@ -37,4 +37,28 @@ export function absMax(...values: number[]) {
 /** Length of an arc with angle in radians */
 export function arcLength(angle: number, radius: number) {
     return angle * radius;
+}
+
+/** Create an outward spiral of given `radius` on a 2d grid */
+export function spiral2d(radius: number) {
+    let x = 0;
+    let y = 0;
+    const delta = [0, -1];
+    const size = radius * 2 + 1;
+    const halfSize = size / 2;
+    const out: [number, number][] = [];
+
+    for (let i = Math.pow(size, 2); i > 0; --i) {
+        if ((-halfSize < x && x <= halfSize) && (-halfSize < y && y <= halfSize)) {
+            out.push([x, y]);
+        }
+
+        if (x === y || (x < 0 && x === -y) || (x > 0 && x === 1 - y)) {
+            [delta[0], delta[1]] = [-delta[1], delta[0]]; // change direction
+        }
+
+        x += delta[0];
+        y += delta[1];
+    }
+    return out;
 }

+ 2 - 2
src/mol-model-formats/structure/basic/atomic.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>
@@ -100,7 +100,7 @@ function getConformation(atom_site: AtomSite): AtomicConformation {
     return {
         id: UUID.create22(),
         atomId: atom_site.id,
-        occupancy: atom_site.occupancy,
+        occupancy: atom_site.occupancy.isDefined ? atom_site.occupancy : Column.ofConst(1, atom_site._rowCount, Column.Schema.float),
         B_iso_or_equiv: atom_site.B_iso_or_equiv,
         xyzDefined: atom_site.Cartn_x.isDefined && atom_site.Cartn_y.isDefined && atom_site.Cartn_z.isDefined,
         x: atom_site.Cartn_x.toArray({ array: Float32Array }),

+ 3 - 2
src/mol-model-formats/structure/pdb/atom-site.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 David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -10,6 +10,7 @@ import { mmCIF_Schema } from '../../../mol-io/reader/cif/schema/mmcif';
 import { TokenBuilder, Tokenizer } from '../../../mol-io/reader/common/text/tokenizer';
 import { guessElementSymbolTokens } from '../util';
 import { Column } from '../../../mol-data/db';
+import { areTokensEmpty } from '../../../mol-io/reader/common/text/column/token';
 
 type AtomSiteTemplate = typeof getAtomSiteTemplate extends (...args: any) => infer T ? T : never
 export function getAtomSiteTemplate(data: string, count: number) {
@@ -63,7 +64,7 @@ export function getAtomSite(sites: AtomSiteTemplate): { [K in keyof mmCIF_Schema
         label_seq_id: CifField.ofUndefined(sites.index, Column.Schema.int),
         label_entity_id: CifField.ofStrings(sites.label_entity_id),
 
-        occupancy: CifField.ofTokens(sites.occupancy),
+        occupancy: areTokensEmpty(sites.occupancy) ? CifField.ofUndefined(sites.index, Column.Schema.float) : CifField.ofTokens(sites.occupancy),
         type_symbol: CifField.ofTokens(sites.type_symbol),
 
         pdbx_PDB_ins_code: CifField.ofTokens(sites.pdbx_PDB_ins_code),

+ 27 - 3
src/mol-model-formats/structure/pdb/entity.ts

@@ -25,7 +25,7 @@ export function parseCmpnd(lines: Tokens, lineStart: number, lineEnd: number) {
 
     let currentSpec: Spec | undefined;
     let currentCompound: EntityCompound = { chains: [], description: '' };
-    const Compounds: EntityCompound[] = [];
+    const compounds: EntityCompound[] = [];
 
     for (let i = lineStart; i < lineEnd; i++) {
         const line = getLine(i);
@@ -55,7 +55,7 @@ export function parseCmpnd(lines: Tokens, lineStart: number, lineEnd: number) {
                 chains: [],
                 description: ''
             };
-            Compounds.push(currentCompound);
+            compounds.push(currentCompound);
         } else if (currentSpec === 'MOLECULE') {
             if (currentCompound.description) currentCompound.description += ' ';
             currentCompound.description += value;
@@ -64,7 +64,31 @@ export function parseCmpnd(lines: Tokens, lineStart: number, lineEnd: number) {
         }
     }
 
-    return Compounds;
+    // Define a seprate entity for each chain
+    // --------------------------------------
+    //
+    // This is a workaround for how sequences are currently determined for PDB files.
+    //
+    // The current approach infers the "observed sequence" from the atomic hierarchy.
+    // However, for example for PDB ID 3HHR, this approach fails, since chains B and C
+    // belong to the same entity but contain different observed sequence, which causes display
+    // errors in the sequence viewer (since the sequences are determined "per entity").
+    //
+    // A better approach could be to parse SEQRES categories and use it to construct
+    // entity_poly_seq category. However, this would require constructing label_seq_id (with gaps)
+    // from RES ID pdb column (auth_seq_id), which isn't a trivial exercise.
+    //
+    // (properly formatted) mmCIF structures do not exhibit this issue.
+    const singletons: EntityCompound[] = [];
+    for (const comp of compounds) {
+        for (const chain of comp.chains) {
+            singletons.push({
+                description: comp.description,
+                chains: [chain]
+            });
+        }
+    }
+    return singletons;
 }
 
 export function parseHetnam(lines: Tokens, lineStart: number, lineEnd: number) {

+ 1 - 1
src/mol-model-formats/structure/pdb/secondary-structure.ts

@@ -102,7 +102,7 @@ export function parseHelix(lines: Tokens, lineStart: number, lineEnd: number): C
     const beg_auth_comp_id = CifField.ofStrings(helices.map(h => h.initResName));
 
     const end_auth_asym_id = CifField.ofStrings(helices.map(h => h.endChainID));
-    const end_auth_comp_id = CifField.ofStrings(helices.map(h => h.endResName));;
+    const end_auth_comp_id = CifField.ofStrings(helices.map(h => h.endResName));
 
     const struct_conf: CifCategory.Fields<mmCIF_Schema['struct_conf']> = {
         beg_label_asym_id: beg_auth_asym_id,

+ 2 - 1
src/mol-model-props/computed/helix-orientation/helix-orientation.ts

@@ -127,7 +127,8 @@ export function calcHelixOrientation(model: Model): HelixOrientation {
         Vec3.fromArray(v2, centers, e3 - 6);
         Vec3.normalize(axis, Vec3.sub(axis, v1, v2));
         const eI = traceElementIndex[e];
-        Vec3.set(a1, x[eI], y[eI], z[eI]);Vec3.copy(vt, a1);
+        Vec3.set(a1, x[eI], y[eI], z[eI]);
+        Vec3.copy(vt, a1);
         Vec3.projectPointOnVector(vt, vt, axis, v1);
         Vec3.toArray(vt, centers, e3);
     }

+ 3 - 3
src/mol-model/loci.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>
  */
@@ -283,8 +283,8 @@ namespace Loci {
      * Converts structure related loci to StructureElement.Loci and applies
      * granularity if given
      */
-    export function normalize(loci: Loci, granularity?: Granularity) {
-        if (granularity !== 'element' && Bond.isLoci(loci)) {
+    export function normalize(loci: Loci, granularity?: Granularity, alwaysConvertBonds = false) {
+        if ((granularity !== 'element' || alwaysConvertBonds) && Bond.isLoci(loci)) {
             // convert Bond.Loci to a StructureElement.Loci so granularity can be applied
             loci = Bond.toStructureElementLoci(loci);
         }

+ 12 - 0
src/mol-model/structure/export/categories/misc.ts

@@ -22,6 +22,18 @@ export const _chem_comp: CifCategory<CifExportContext> = {
     }
 };
 
+export const _chem_comp_bond: CifCategory<CifExportContext> = {
+    name: 'chem_comp_bond',
+    instance({ firstModel, structures, cache }) {
+        const chem_comp_bond = getModelMmCifCategory(structures[0].model, 'chem_comp_bond');
+        if (!chem_comp_bond) return CifCategory.Empty;
+        const { comp_id } = chem_comp_bond;
+        const names = cache.uniqueResidueNames || (cache.uniqueResidueNames = getUniqueResidueNamesFromStructures(structures));
+        const indices = Column.indicesOf(comp_id, id => names.has(id));
+        return CifCategory.ofTable(chem_comp_bond, indices);
+    }
+};
+
 export const _pdbx_chem_comp_identifier: CifCategory<CifExportContext> = {
     name: 'pdbx_chem_comp_identifier',
     instance({ firstModel, structures, cache }) {

+ 5 - 2
src/mol-model/structure/export/mmcif.ts

@@ -11,7 +11,7 @@ import { Structure } from '../structure';
 import { _atom_site } from './categories/atom_site';
 import CifCategory = CifWriter.Category
 import { _struct_conf, _struct_sheet_range } from './categories/secondary-structure';
-import { _chem_comp, _pdbx_chem_comp_identifier, _pdbx_nonpoly_scheme } from './categories/misc';
+import { _chem_comp, _chem_comp_bond, _pdbx_chem_comp_identifier, _pdbx_nonpoly_scheme } from './categories/misc';
 import { Model } from '../model';
 import { getUniqueEntityIndicesFromStructures, copy_mmCif_category, copy_source_mmCifCategory } from './categories/utils';
 import { _struct_asym, _entity_poly, _entity_poly_seq } from './categories/sequence';
@@ -81,9 +81,12 @@ const Categories = [
     copy_mmCif_category('pdbx_entity_branch_link'),
     copy_mmCif_category('pdbx_branch_scheme'),
 
+    // Struct conn
+    copy_mmCif_category('struct_conn'),
+
     // Misc
-    // TODO: filter for actual present residues?
     _chem_comp,
+    _chem_comp_bond,
     _pdbx_chem_comp_identifier,
     copy_mmCif_category('atom_sites'),
 

+ 39 - 5
src/mol-model/structure/structure/carbohydrates/constants.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2019 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>
  * @author David Sehnal <david.sehnal@gmail.com>
@@ -309,14 +309,48 @@ const UnknownSaccharideNames = [
     'PUF', 'GDA', '9WJ', // via updated CCD
 ];
 
+/**
+ * From http://glycam.org/docs/othertoolsservice/2016/06/09/3d-snfg-list-of-residue-names/#CHARMM
+ */
+const CharmmSaccharideNames: { [k: string]: string[] } = {
+    Glc: ['AGLC', 'BGLC'],
+    GlcNAc: ['AGLCNA', 'BGLCNA', 'BGLCN0'],
+    GlcA: ['AGLCA', 'BGLCA', 'BGLCA0'],
+    Man: ['AMAN', 'BMAN'],
+    Rha: ['ARHM', 'BRHM'],
+    Ara: ['AARB', 'BARB'],
+    Gal: ['AGAL', 'BGAL'],
+    GalNAc: ['AGALNA', 'BGALNA'],
+    Gul: ['AGUL', 'BGUL'],
+    Alt: ['AALT', 'BALT'],
+    All: ['AALL', 'BALL'],
+    Tal: ['ATAL', 'BTAL'],
+    Ido: ['AIDO', 'BIDO'],
+    IdoA: ['AIDOA', 'BIDOA'],
+    Fuc: ['AFUC', 'BFUC'],
+    Lyx: ['ALYF', 'BLYF'],
+    Xyl: ['AXYL', 'BXYL', 'AXYF', 'BXYF'],
+    Rib: ['ARIB', 'BRIB'],
+    Fru: ['AFRU', 'BFRU'],
+    Neu5Ac: ['ANE5AC', 'BNE5AC'],
+};
+
 export const SaccharideCompIdMap = (function () {
     const map = new Map<string, SaccharideComponent>();
     for (let i = 0, il = Monosaccharides.length; i < il; ++i) {
         const saccharide = Monosaccharides[i];
-        const names = CommonSaccharideNames[saccharide.abbr];
-        if (names) {
-            for (let j = 0, jl = names.length; j < jl; ++j) {
-                map.set(names[j], saccharide);
+
+        const common = CommonSaccharideNames[saccharide.abbr];
+        if (common) {
+            for (let j = 0, jl = common.length; j < jl; ++j) {
+                map.set(common[j], saccharide);
+            }
+        }
+
+        const charmm = CharmmSaccharideNames[saccharide.abbr];
+        if (charmm) {
+            for (let j = 0, jl = charmm.length; j < jl; ++j) {
+                map.set(charmm[j], saccharide);
             }
         }
     }

+ 14 - 0
src/mol-model/structure/structure/element/loci.ts

@@ -582,6 +582,20 @@ export namespace Loci {
         return PrincipalAxes.ofPositions(positions);
     }
 
+    export function getPrincipalAxesMany(locis: Loci[]): PrincipalAxes {
+        let elementCount = 0;
+        locis.forEach(l => {
+            elementCount += size(l);
+        });
+        const positions = new Float32Array(3 * elementCount);
+        let offset = 0;
+        locis.forEach(l => {
+            toPositionsArray(l, positions, offset);
+            offset += size(l) * 3;
+        });
+        return PrincipalAxes.ofPositions(positions);
+    }
+
     function sourceIndex(unit: Unit, element: ElementIndex) {
         return Unit.isAtomic(unit)
             ? unit.model.atomicHierarchy.atomSourceIndex.value(element)

+ 2 - 2
src/mol-model/structure/structure/unit.ts

@@ -369,7 +369,7 @@ namespace Unit {
         readonly props: CoarseProperties;
 
         getChild(elements: StructureElement.Set): Unit {
-            if (elements.length === this.elements.length) return this as any as Unit /** lets call this an ugly temporary hack */;
+            if (elements.length === this.elements.length) return this as any as Unit; // lets call this an ugly temporary hack
             return createCoarse(this.id, this.invariantId, this.chainGroupId, this.traits, this.model, this.kind, elements, this.conformation, CoarseProperties());
         }
 
@@ -465,7 +465,7 @@ namespace Unit {
     export class Gaussians extends Coarse<Kind.Gaussians, CoarseGaussianConformation> { }
 
     function createCoarse<K extends Kind.Gaussians | Kind.Spheres>(id: number, invariantId: number, chainGroupId: number, traits: Traits, model: Model, kind: K, elements: StructureElement.Set, conformation: SymmetryOperator.ArrayMapping<ElementIndex>, props: CoarseProperties): K extends Kind.Spheres ? Spheres : Gaussians {
-        return new Coarse(id, invariantId, chainGroupId, traits, model, kind, elements, conformation, props) as any /** lets call this an ugly temporary hack */;
+        return new Coarse(id, invariantId, chainGroupId, traits, model, kind, elements, conformation, props) as any; // lets call this an ugly temporary hack
     }
 
     export function areSameChainOperatorGroup(a: Unit, b: Unit) {

+ 7 - 2
src/mol-model/structure/structure/unit/bonds.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>
@@ -8,7 +8,7 @@
 import { Unit, StructureElement } from '../../structure';
 import { Structure } from '../structure';
 import { BondType } from '../../model/types';
-import { SortedArray, Iterator } from '../../../../mol-data/int';
+import { SortedArray, Iterator, OrderedSet } from '../../../../mol-data/int';
 import { CentroidHelper } from '../../../../mol-math/geometry/centroid-helper';
 import { Sphere3D } from '../../../../mol-math/geometry';
 
@@ -132,6 +132,11 @@ namespace Bond {
         return StructureElement.Loci(loci.structure, elements);
     }
 
+    export function toFirstStructureElementLoci(loci: Loci): StructureElement.Loci {
+        const { aUnit, aIndex } = loci.bonds[0];
+        return StructureElement.Loci(loci.structure, [{ unit: aUnit, indices: OrderedSet.ofSingleton(aIndex) }]);
+    }
+
     export function getType(structure: Structure, location: Location<Unit.Atomic>): BondType {
         if (location.aUnit === location.bUnit) {
             const bonds = location.aUnit.bonds;

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

@@ -79,7 +79,7 @@ function findIndexPairBonds(unit: Unit.Atomic) {
             if ((d !== -1 && equalEps(dist, d, 0.5)) || dist < maxDistance) {
                 atomA[atomA.length] = _aI;
                 atomB[atomB.length] = _bI;
-                orders[order.length] = order[i];
+                orders[orders.length] = order[i];
                 flags[flags.length] = flag[i];
             }
         }
@@ -133,7 +133,7 @@ function findBonds(unit: Unit.Atomic, props: BondComputationProps): IntraUnitBon
 
                 const p = partnerA.atomIndex === aI ? partnerB : partnerA;
                 const _bI = SortedArray.indexOf(unit.elements, p.atomIndex) as StructureElement.UnitIndex;
-                if (_bI < 0) continue;
+                if (_bI < 0 || atoms[_bI] < aI) continue;
 
                 atomA[atomA.length] = _aI;
                 atomB[atomB.length] = _bI;

+ 12 - 10
src/mol-plugin-state/manager/interactivity.ts

@@ -101,10 +101,10 @@ namespace InteractivityManager {
             // TODO clear, then re-apply remaining providers
         }
 
-        protected normalizedLoci(reprLoci: Representation.Loci, applyGranularity = true) {
+        protected normalizedLoci(reprLoci: Representation.Loci, applyGranularity: boolean, alwaysConvertBonds = false) {
             const { loci, repr } = reprLoci;
             const granularity = applyGranularity ? this.props.granularity : undefined;
-            return { loci: Loci.normalize(loci, granularity), repr };
+            return { loci: Loci.normalize(loci, granularity, alwaysConvertBonds), repr };
         }
 
         protected mark(current: Representation.Loci, action: MarkerAction, noRender = false) {
@@ -187,7 +187,8 @@ namespace InteractivityManager {
         toggle(current: Representation.Loci, applyGranularity = true) {
             if (Loci.isEmpty(current.loci)) return;
 
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
+
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.toggleSel(normalized);
             } else {
@@ -198,7 +199,7 @@ namespace InteractivityManager {
         toggleExtend(current: Representation.Loci, applyGranularity = true) {
             if (Loci.isEmpty(current.loci)) return;
 
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 const loci = this.sel.tryGetRange(normalized.loci) || normalized.loci;
                 this.toggleSel({ loci, repr: normalized.repr });
@@ -206,7 +207,7 @@ namespace InteractivityManager {
         }
 
         select(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.sel.modify('add', normalized.loci);
             }
@@ -214,7 +215,7 @@ namespace InteractivityManager {
         }
 
         selectJoin(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.sel.modify('intersect', normalized.loci);
             }
@@ -222,7 +223,7 @@ namespace InteractivityManager {
         }
 
         selectOnly(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 // only deselect for the structure of the given loci
                 this.deselect({ loci: Structure.toStructureElementLoci(normalized.loci.structure), repr: normalized.repr }, false);
@@ -232,7 +233,7 @@ namespace InteractivityManager {
         }
 
         deselect(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.sel.modify('remove', normalized.loci);
             }
@@ -255,8 +256,9 @@ namespace InteractivityManager {
                     // do a full deselect/select for the current structure so visuals that are
                     // marked with granularity unequal to 'element' and join/intersect operations
                     // are handled properly
-                    super.mark({ loci: Structure.Loci(loci.structure) }, MarkerAction.Deselect, true);
-                    super.mark({ loci: this.sel.getLoci(loci.structure) }, MarkerAction.Select);
+                    const selLoci = this.sel.getLoci(loci.structure);
+                    super.mark({ loci: Structure.Loci(loci.structure) }, MarkerAction.Deselect, !Loci.isEmpty(selLoci));
+                    super.mark({ loci: selLoci }, MarkerAction.Select);
                 } else {
                     super.mark(current, action);
                 }

+ 48 - 10
src/mol-plugin-state/manager/structure/measurement.ts

@@ -1,7 +1,8 @@
 /**
- * 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 David Sehnal <david.sehnal@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
 import { StructureElement } from '../../../mol-model/structure';
@@ -15,6 +16,7 @@ import { StatefulPluginComponent } from '../../component';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { MeasurementRepresentationCommonTextParams, LociLabelTextParams } from '../../../mol-repr/shape/loci/common';
 import { LineParams } from '../../../mol-repr/structure/representation/line';
+import { Expression } from '../../../mol-script/language/expression';
 
 export { StructureMeasurementManager };
 
@@ -35,6 +37,7 @@ export interface StructureMeasurementManagerState {
     angles: StructureMeasurementCell[],
     dihedrals: StructureMeasurementCell[],
     orientations: StructureMeasurementCell[],
+    planes: StructureMeasurementCell[],
     options: StructureMeasurementOptions
 }
 
@@ -222,19 +225,25 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
-    async addOrientation(a: StructureElement.Loci) {
-        const cellA = this.plugin.helpers.substructureParent.get(a.structure);
+    async addOrientation(locis: StructureElement.Loci[]) {
+        const selections: { key: string, ref: string, groupId?: string, expression: Expression }[] = [];
+        const dependsOn: string[] = [];
 
-        if (!cellA) return;
+        for (let i = 0, il = locis.length; i < il; ++i) {
+            const l = locis[i];
+            const cell = this.plugin.helpers.substructureParent.get(l.structure);
+            if (!cell) continue;
 
-        const dependsOn = [cellA.transform.ref];
+            arraySetAdd(dependsOn, cell.transform.ref);
+            selections.push({ key: `l${i}`, ref: cell.transform.ref, expression: StructureElement.Loci.toExpression(l) });
+        }
+
+        if (selections.length === 0) return;
 
         const update = this.getGroup();
         update
             .apply(StateTransforms.Model.MultiStructureSelectionFromExpression, {
-                selections: [
-                    { key: 'a', ref: cellA.transform.ref, expression: StructureElement.Loci.toExpression(a) },
-                ],
+                selections,
                 isTransitive: true,
                 label: 'Orientation'
             }, { dependsOn })
@@ -244,6 +253,34 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
+    async addPlane(locis: StructureElement.Loci[]) {
+        const selections: { key: string, ref: string, groupId?: string, expression: Expression }[] = [];
+        const dependsOn: string[] = [];
+
+        for (let i = 0, il = locis.length; i < il; ++i) {
+            const l = locis[i];
+            const cell = this.plugin.helpers.substructureParent.get(l.structure);
+            if (!cell) continue;
+
+            arraySetAdd(dependsOn, cell.transform.ref);
+            selections.push({ key: `l${i}`, ref: cell.transform.ref, expression: StructureElement.Loci.toExpression(l) });
+        }
+
+        if (selections.length === 0) return;
+
+        const update = this.getGroup();
+        update
+            .apply(StateTransforms.Model.MultiStructureSelectionFromExpression, {
+                selections,
+                isTransitive: true,
+                label: 'Plane'
+            }, { dependsOn })
+            .apply(StateTransforms.Representation.StructureSelectionsPlane3D);
+
+        const state = this.plugin.state.data;
+        await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+    }
+
     private _empty: any[] = [];
     private getTransforms<T extends StateTransformer<A, B, any>, A extends PluginStateObject.Molecule.Structure.Selections, B extends StateObject>(transformer: T) {
         const state = this.plugin.state.data;
@@ -259,13 +296,14 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
             distances: this.getTransforms(StateTransforms.Representation.StructureSelectionsDistance3D),
             angles: this.getTransforms(StateTransforms.Representation.StructureSelectionsAngle3D),
             dihedrals: this.getTransforms(StateTransforms.Representation.StructureSelectionsDihedral3D),
-            orientations: this.getTransforms(StateTransforms.Representation.StructureSelectionsOrientation3D)
+            orientations: this.getTransforms(StateTransforms.Representation.StructureSelectionsOrientation3D),
+            planes: this.getTransforms(StateTransforms.Representation.StructureSelectionsPlane3D),
         });
         if (updated) this.stateUpdated();
     }
 
     constructor(private plugin: PluginContext) {
-        super({ labels: [], distances: [], angles: [], dihedrals: [], orientations: [], options: DefaultStructureMeasurementOptions });
+        super({ labels: [], distances: [], angles: [], dihedrals: [], orientations: [], planes: [], options: DefaultStructureMeasurementOptions });
 
         plugin.state.data.events.changed.subscribe(e => {
             if (e.inTransaction || plugin.behaviors.state.isAnimating.value) return;

+ 4 - 9
src/mol-plugin-state/manager/structure/selection.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 David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -22,6 +22,7 @@ import { PluginStateObject as PSO } from '../../objects';
 import { UUID } from '../../../mol-util';
 import { StructureRef } from './hierarchy-state';
 import { Boundary } from '../../../mol-math/geometry/boundary';
+import { iterableToArray } from '../../../mol-data/util';
 
 interface StructureSelectionManagerState {
     entries: Map<string, SelectionEntry>,
@@ -405,14 +406,8 @@ export class StructureSelectionManager extends StatefulPluginComponent<Structure
     }
 
     getPrincipalAxes(): PrincipalAxes {
-        const elementCount = this.elementCount();
-        const positions = new Float32Array(3 * elementCount);
-        let offset = 0;
-        this.entries.forEach(v => {
-            StructureElement.Loci.toPositionsArray(v.selection, positions, offset);
-            offset += StructureElement.Loci.size(v.selection) * 3;
-        });
-        return PrincipalAxes.ofPositions(positions);
+        const values = iterableToArray(this.entries.values());
+        return StructureElement.Loci.getPrincipalAxesMany(values.map(v => v.selection));
     }
 
     modify(modifier: StructureSelectionModifier, loci: Loci) {

+ 7 - 3
src/mol-plugin-state/transforms/helpers.ts

@@ -1,5 +1,5 @@
 /**
- * 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 Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -10,6 +10,7 @@ import { LabelData } from '../../mol-repr/shape/loci/label';
 import { OrientationData } from '../../mol-repr/shape/loci/orientation';
 import { AngleData } from '../../mol-repr/shape/loci/angle';
 import { DihedralData } from '../../mol-repr/shape/loci/dihedral';
+import { PlaneData } from '../../mol-repr/shape/loci/plane';
 
 export function getDistanceDataFromStructureSelections(s: ReadonlyArray<PluginStateObject.Molecule.Structure.SelectionEntry>): DistanceData {
     const lociA = s[0].loci;
@@ -38,6 +39,9 @@ export function getLabelDataFromStructureSelections(s: ReadonlyArray<PluginState
 }
 
 export function getOrientationDataFromStructureSelections(s: ReadonlyArray<PluginStateObject.Molecule.Structure.SelectionEntry>): OrientationData {
-    const loci = s[0].loci;
-    return { locis: [loci] };
+    return { locis: s.map(v => v.loci) };
+}
+
+export function getPlaneDataFromStructureSelections(s: ReadonlyArray<PluginStateObject.Molecule.Structure.SelectionEntry>): PlaneData {
+    return { locis: s.map(v => v.loci) };
 }

+ 2 - 1
src/mol-plugin-state/transforms/model.ts

@@ -645,7 +645,8 @@ const MultiStructureSelectionFromExpression = PluginStateTransform.BuiltIn({
                     totalSize += StructureElement.Loci.size(loci.loci);
 
                     continue;
-                } if (entry.expression !== sel.expression) {
+                }
+                if (entry.expression !== sel.expression) {
                     recreate = true;
                 } else {
                     // TODO: properly support "transitive" queries. For that Structure.areUnitAndIndicesEqual needs to be fixed;

+ 36 - 2
src/mol-plugin-state/transforms/representation.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 David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -28,7 +28,7 @@ import { BaseGeometry } from '../../mol-geo/geometry/base';
 import { Script } from '../../mol-script/script';
 import { UnitcellParams, UnitcellRepresentation, getUnitcellData } from '../../mol-repr/shape/model/unitcell';
 import { DistanceParams, DistanceRepresentation } from '../../mol-repr/shape/loci/distance';
-import { getDistanceDataFromStructureSelections, getLabelDataFromStructureSelections, getOrientationDataFromStructureSelections, getAngleDataFromStructureSelections, getDihedralDataFromStructureSelections } from './helpers';
+import { getDistanceDataFromStructureSelections, getLabelDataFromStructureSelections, getOrientationDataFromStructureSelections, getAngleDataFromStructureSelections, getDihedralDataFromStructureSelections, getPlaneDataFromStructureSelections } from './helpers';
 import { LabelParams, LabelRepresentation } from '../../mol-repr/shape/loci/label';
 import { OrientationRepresentation, OrientationParams } from '../../mol-repr/shape/loci/orientation';
 import { AngleParams, AngleRepresentation } from '../../mol-repr/shape/loci/angle';
@@ -40,6 +40,7 @@ import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
 import { getBoxMesh } from './shape';
 import { Shape } from '../../mol-model/shape';
 import { Box3D } from '../../mol-math/geometry';
+import { PlaneParams, PlaneRepresentation } from '../../mol-repr/shape/loci/plane';
 
 export { StructureRepresentation3D };
 export { ExplodeStructureRepresentation3D };
@@ -986,4 +987,37 @@ const StructureSelectionsOrientation3D = PluginStateTransform.BuiltIn({
             return StateTransformer.UpdateResult.Updated;
         });
     },
+});
+
+export { StructureSelectionsPlane3D };
+type StructureSelectionsPlane3D = typeof StructureSelectionsPlane3D
+const StructureSelectionsPlane3D = PluginStateTransform.BuiltIn({
+    name: 'structure-selections-plane-3d',
+    display: '3D Plane',
+    from: SO.Molecule.Structure.Selections,
+    to: SO.Shape.Representation3D,
+    params: () => ({
+        ...PlaneParams,
+    })
+})({
+    canAutoUpdate({ oldParams, newParams }) {
+        return true;
+    },
+    apply({ a, params }, plugin: PluginContext) {
+        return Task.create('Structure Plane', async ctx => {
+            const data = getPlaneDataFromStructureSelections(a.data);
+            const repr = PlaneRepresentation({ webgl: plugin.canvas3d?.webgl, ...plugin.representation.structure.themes }, () => PlaneParams);
+            await repr.createOrUpdate(params, data).runInContext(ctx);
+            return new SO.Shape.Representation3D({ repr, sourceData: data }, { label: `Plane` });
+        });
+    },
+    update({ a, b, oldParams, newParams }, plugin: PluginContext) {
+        return Task.create('Structure Plane', async ctx => {
+            const props = { ...b.data.repr.props, ...newParams };
+            const data = getPlaneDataFromStructureSelections(a.data);
+            await b.data.repr.createOrUpdate(props, data).runInContext(ctx);
+            b.data.sourceData = data;
+            return StateTransformer.UpdateResult.Updated;
+        });
+    },
 });

+ 2 - 2
src/mol-plugin-ui/controls/line-graph/line-graph-component.tsx

@@ -207,7 +207,7 @@ export class LineGraphComponent extends React.Component<any, LineGraphComponentS
 
         const updatedPoint = this.unNormalizePoint(Vec2.create(this.updatedX, this.updatedY));
         const points = this.state.points.filter((_, i) => i !== selected[0]);
-        points.push(updatedPoint);;
+        points.push(updatedPoint);
         points.sort((a, b) => {
             if (a[0] === b[0]) {
                 if (a[0] === 0) {
@@ -372,7 +372,7 @@ export class LineGraphComponent extends React.Component<any, LineGraphComponentS
         const data = points;
         const size = data.length;
 
-        for (let i = 0; i < size - 1;i++) {
+        for (let i = 0; i < size - 1; i++) {
             const x1 = data[i][0];
             const y1 = data[i][1];
             const x2 = data[i + 1][0];

+ 5 - 1
src/mol-plugin-ui/sequence/sequence.tsx

@@ -153,7 +153,11 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
     private getBackgroundColor(marker: number) {
         // TODO: make marker color configurable
         if (typeof marker === 'undefined') console.error('unexpected marker value');
-        return marker === 0 ? '' : marker % 2 === 0 ? 'rgb(51, 255, 25)' /* selected */ : 'rgb(255, 102, 153)' /* highlighted */;
+        return marker === 0
+            ? ''
+            : marker % 2 === 0
+                ? 'rgb(51, 255, 25)' // selected
+                : 'rgb(255, 102, 153)'; // highlighted
     }
 
     private getResidueClass(seqIdx: number, label: string) {

+ 30 - 7
src/mol-plugin-ui/structure/measurements.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-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>
@@ -15,7 +15,8 @@ import { AngleData } from '../../mol-repr/shape/loci/angle';
 import { DihedralData } from '../../mol-repr/shape/loci/dihedral';
 import { DistanceData } from '../../mol-repr/shape/loci/distance';
 import { LabelData } from '../../mol-repr/shape/loci/label';
-import { angleLabel, dihedralLabel, distanceLabel, lociLabel } from '../../mol-theme/label';
+import { OrientationData } from '../../mol-repr/shape/loci/orientation';
+import { angleLabel, dihedralLabel, distanceLabel, lociLabel, structureElementLociLabelMany } from '../../mol-theme/label';
 import { FiniteArray } from '../../mol-util/type-helpers';
 import { CollapsableControls, PurePluginUIComponent } from '../base';
 import { ActionMenu } from '../controls/action-menu';
@@ -67,6 +68,8 @@ export class MeasurementList extends PurePluginUIComponent {
             {this.renderGroup(measurements.distances, 'Distances')}
             {this.renderGroup(measurements.angles, 'Angles')}
             {this.renderGroup(measurements.dihedrals, 'Dihedrals')}
+            {this.renderGroup(measurements.orientations, 'Orientations')}
+            {this.renderGroup(measurements.planes, 'Planes')}
         </div>;
     }
 }
@@ -108,13 +111,31 @@ export class MeasurementControls extends PurePluginUIComponent<{}, { isBusy: boo
         this.plugin.managers.structure.measurement.addLabel(loci[0].loci);
     }
 
+    addOrientation = () => {
+        const locis: StructureElement.Loci[] = [];
+        this.plugin.managers.structure.selection.entries.forEach(v => {
+            locis.push(v.selection);
+        });
+        this.plugin.managers.structure.measurement.addOrientation(locis);
+    }
+
+    addPlane = () => {
+        const locis: StructureElement.Loci[] = [];
+        this.plugin.managers.structure.selection.entries.forEach(v => {
+            locis.push(v.selection);
+        });
+        this.plugin.managers.structure.measurement.addPlane(locis);
+    }
+
     get actions(): ActionMenu.Items {
         const history = this.selection.additionsHistory;
         const ret: ActionMenu.Item[] = [
-            { kind: 'item', label: `Label ${history.length === 0 ? ' (1 selection required)' : ' (1st selection)'}`, value: this.addLabel, disabled: history.length === 0 },
-            { kind: 'item', label: `Distance ${history.length < 2 ? ' (2 selections required)' : ' (top 2 selections)'}`, value: this.measureDistance, disabled: history.length < 2 },
-            { kind: 'item', label: `Angle ${history.length < 3 ? ' (3 selections required)' : ' (top 3 selections)'}`, value: this.measureAngle, disabled: history.length < 3 },
-            { kind: 'item', label: `Dihedral ${history.length < 4 ? ' (4 selections required)' : ' (top 4 selections)'}`, value: this.measureDihedral, disabled: history.length < 4 },
+            { kind: 'item', label: `Label ${history.length === 0 ? ' (1 selection item required)' : ' (1st selection item)'}`, value: this.addLabel, disabled: history.length === 0 },
+            { kind: 'item', label: `Distance ${history.length < 2 ? ' (2 selection items required)' : ' (top 2 selection items)'}`, value: this.measureDistance, disabled: history.length < 2 },
+            { kind: 'item', label: `Angle ${history.length < 3 ? ' (3 selection items required)' : ' (top 3 items)'}`, value: this.measureAngle, disabled: history.length < 3 },
+            { kind: 'item', label: `Dihedral ${history.length < 4 ? ' (4 selection items required)' : ' (top 4 selection items)'}`, value: this.measureDihedral, disabled: history.length < 4 },
+            { kind: 'item', label: `Orientation ${history.length === 0 ? ' (selection required)' : ' (current selection)'}`, value: this.addOrientation, disabled: history.length === 0 },
+            { kind: 'item', label: `Plane ${history.length === 0 ? ' (selection required)' : ' (current selection)'}`, value: this.addPlane, disabled: history.length === 0 },
         ];
         return ret;
     }
@@ -219,7 +240,7 @@ class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasuremen
     }
 
     get selections() {
-        return this.props.cell.obj?.data.sourceData as Partial<DistanceData & AngleData & DihedralData & LabelData> | undefined;
+        return this.props.cell.obj?.data.sourceData as Partial<DistanceData & AngleData & DihedralData & LabelData & OrientationData> | undefined;
     }
 
     delete = () => {
@@ -266,6 +287,7 @@ class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasuremen
         if (selections.pairs) return selections.pairs[0].loci;
         if (selections.triples) return selections.triples[0].loci;
         if (selections.quads) return selections.quads[0].loci;
+        if (selections.locis) return selections.locis;
         return [];
     }
 
@@ -277,6 +299,7 @@ class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasuremen
         if (selections.pairs) return distanceLabel(selections.pairs[0], { condensed: true, unitLabel: this.plugin.managers.structure.measurement.state.options.distanceUnitLabel });
         if (selections.triples) return angleLabel(selections.triples[0], { condensed: true });
         if (selections.quads) return dihedralLabel(selections.quads[0], { condensed: true });
+        if (selections.locis) return structureElementLociLabelMany(selections.locis, { countsOnly: true });
         return '<empty>';
     }
 

+ 26 - 10
src/mol-plugin/behavior/dynamic/representation.ts

@@ -16,7 +16,7 @@ import { ButtonsType, ModifiersKeys } from '../../../mol-util/input/input-observ
 import { Binding } from '../../../mol-util/binding';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { EmptyLoci, Loci } from '../../../mol-model/loci';
-import { Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
+import { Bond, Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
 import { arrayMax } from '../../../mol-util/array';
 import { Representation } from '../../../mol-repr/representation';
 import { LociLabel } from '../../../mol-plugin-state/manager/loci-label';
@@ -34,6 +34,7 @@ const DefaultHighlightLociBindings = {
 const HighlightLociParams = {
     bindings: PD.Value(DefaultHighlightLociBindings, { isHidden: true }),
     ignore: PD.Value<Loci['kind'][]>([], { isHidden: true }),
+    preferAtoms: PD.Boolean(false, { description: 'Always prefer atoms over bonds' }),
     mark: PD.Boolean(true)
 };
 type HighlightLociProps = PD.Values<typeof HighlightLociParams>
@@ -46,10 +47,17 @@ export const HighlightLoci = PluginBehavior.create({
             if (!this.ctx.canvas3d || !this.params.mark) return;
             this.ctx.canvas3d.mark(interactionLoci, action, noRender);
         }
+        private getLoci(loci: Loci) {
+            return this.params.preferAtoms && Bond.isLoci(loci) && loci.bonds.length === 2
+                ? Bond.toFirstStructureElementLoci(loci)
+                : loci;
+        }
         register() {
             this.subscribeObservable(this.ctx.behaviors.interaction.hover, ({ current, buttons, modifiers }) => {
                 if (!this.ctx.canvas3d || this.ctx.isBusy) return;
-                if (this.params.ignore?.indexOf(current.loci.kind) >= 0) {
+
+                const loci = this.getLoci(current.loci);
+                if (this.params.ignore?.indexOf(loci.kind) >= 0) {
                     this.ctx.managers.interactivity.lociHighlights.highlightOnly({ repr: current.repr, loci: EmptyLoci });
                     return;
                 }
@@ -58,13 +66,13 @@ export const HighlightLoci = PluginBehavior.create({
 
                 if (Binding.match(this.params.bindings.hoverHighlightOnly, buttons, modifiers)) {
                     // remove repr to highlight loci everywhere on hover
-                    this.ctx.managers.interactivity.lociHighlights.highlightOnly({ loci: current.loci });
+                    this.ctx.managers.interactivity.lociHighlights.highlightOnly({ loci });
                     matched = true;
                 }
 
                 if (Binding.match(this.params.bindings.hoverHighlightOnlyExtend, buttons, modifiers)) {
                     // remove repr to highlight loci everywhere on hover
-                    this.ctx.managers.interactivity.lociHighlights.highlightOnlyExtend({ loci: current.loci });
+                    this.ctx.managers.interactivity.lociHighlights.highlightOnlyExtend({ loci });
                     matched = true;
                 }
 
@@ -95,6 +103,7 @@ const DefaultSelectLociBindings = {
 const SelectLociParams = {
     bindings: PD.Value(DefaultSelectLociBindings, { isHidden: true }),
     ignore: PD.Value<Loci['kind'][]>([], { isHidden: true }),
+    preferAtoms: PD.Boolean(false, { description: 'Always prefer atoms over bonds' }),
     mark: PD.Boolean(true)
 };
 type SelectLociProps = PD.Values<typeof SelectLociParams>
@@ -108,6 +117,11 @@ export const SelectLoci = PluginBehavior.create({
             if (!this.ctx.canvas3d || !this.params.mark) return;
             this.ctx.canvas3d.mark({ loci: reprLoci.loci }, action, noRender);
         }
+        private getLoci(loci: Loci) {
+            return this.params.preferAtoms && Bond.isLoci(loci) && loci.bonds.length === 2
+                ? Bond.toFirstStructureElementLoci(loci)
+                : loci;
+        }
         private applySelectMark(ref: string, clear?: boolean) {
             const cell = this.ctx.state.data.cells.get(ref);
             if (cell && SO.isRepresentation3D(cell.obj)) {
@@ -123,10 +137,10 @@ export const SelectLoci = PluginBehavior.create({
             }
         }
         register() {
-            const lociIsEmpty = (current: Representation.Loci) => Loci.isEmpty(current.loci);
-            const lociIsNotEmpty = (current: Representation.Loci) => !Loci.isEmpty(current.loci);
+            const lociIsEmpty = (loci: Loci) => Loci.isEmpty(loci);
+            const lociIsNotEmpty = (loci: Loci) => !Loci.isEmpty(loci);
 
-            const actions: [keyof typeof DefaultSelectLociBindings, (current: Representation.Loci) => void, ((current: Representation.Loci) => boolean) | undefined][] = [
+            const actions: [keyof typeof DefaultSelectLociBindings, (current: Representation.Loci) => void, ((current: Loci) => boolean) | undefined][] = [
                 ['clickSelect', current => this.ctx.managers.interactivity.lociSelects.select(current), lociIsNotEmpty],
                 ['clickToggle', current => this.ctx.managers.interactivity.lociSelects.toggle(current), lociIsNotEmpty],
                 ['clickToggleExtend', current => this.ctx.managers.interactivity.lociSelects.toggleExtend(current), lociIsNotEmpty],
@@ -145,12 +159,14 @@ export const SelectLoci = PluginBehavior.create({
 
             this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, button, modifiers }) => {
                 if (!this.ctx.canvas3d || this.ctx.isBusy || !this.ctx.selectionMode) return;
-                if (this.params.ignore?.indexOf(current.loci.kind) >= 0) return;
+
+                const loci = this.getLoci(current.loci);
+                if (this.params.ignore?.indexOf(loci.kind) >= 0) return;
 
                 // only trigger the 1st action that matches
                 for (const [binding, action, condition] of actions) {
-                    if (Binding.match(this.params.bindings[binding], button, modifiers) && (!condition || condition(current))) {
-                        action(current);
+                    if (Binding.match(this.params.bindings[binding], button, modifiers) && (!condition || condition(loci))) {
+                        action({ repr: current.repr, loci });
                         break;
                     }
                 }

+ 1 - 1
src/mol-plugin/behavior/dynamic/selection/structure-focus-representation.ts

@@ -25,7 +25,7 @@ const StructureFocusRepresentationParams = (plugin: PluginContext) => {
         expandRadius: PD.Numeric(5, { min: 1, max: 10, step: 1 }),
         targetParams: PD.Group(reprParams, {
             label: 'Target',
-            customDefault: createStructureRepresentationParams(plugin, void 0, { type: 'ball-and-stick', size: 'physical', typeParams: { sizeFactor: 0.26, alpha: 0.51 } })
+            customDefault: createStructureRepresentationParams(plugin, void 0, { type: 'ball-and-stick', size: 'physical', typeParams: { sizeFactor: 0.26, alpha: 0.51, adjustCylinderLength: true } })
         }),
         surroundingsParams: PD.Group(reprParams, {
             label: 'Surroundings',

+ 14 - 0
src/mol-plugin/config.ts

@@ -19,6 +19,16 @@ export class PluginConfigItem<T = any> {
 
 function item<T>(key: string, defaultValue?: T) { return new PluginConfigItem(key, defaultValue); }
 
+
+// adapted from https://stackoverflow.com/questions/9038625/detect-if-device-is-ios
+function is_iOS() {
+    if (typeof navigator === 'undefined' || typeof window === 'undefined') return false;
+    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+    const isAppleDevice = navigator.userAgent.includes('Macintosh');
+    const isTouchScreen = navigator.maxTouchPoints >= 4; // true for iOS 13 (and hopefully beyond)
+    return !(window as any).MSStream && (isIOS || (isAppleDevice && isTouchScreen));
+}
+
 export const PluginConfig = {
     item,
     General: {
@@ -27,7 +37,11 @@ export const PluginConfig = {
         DisablePreserveDrawingBuffer: item('plugin-config.disable-preserve-drawing-buffer', false),
         PixelScale: item('plugin-config.pixel-scale', 1),
         PickScale: item('plugin-config.pick-scale', 0.25),
+        PickPadding: item('plugin-config.pick-padding', 3),
         EnableWboit: item('plugin-config.enable-wboit', true),
+        // as of Oct 1 2021, WebGL 2 doesn't work on iOS 15.
+        // TODO: check back in a few weeks to see if it was fixed
+        PreferWebGl1: item('plugin-config.prefer-webgl1', is_iOS()),
     },
     State: {
         DefaultServer: item('plugin-state.server', 'https://webchem.ncbr.muni.cz/molstar-state'),

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

@@ -196,8 +196,11 @@ export class PluginContext {
                 const preserveDrawingBuffer = !(this.config.get(PluginConfig.General.DisablePreserveDrawingBuffer) ?? false);
                 const pixelScale = this.config.get(PluginConfig.General.PixelScale) || 1;
                 const pickScale = this.config.get(PluginConfig.General.PickScale) || 0.25;
+                const pickPadding = this.config.get(PluginConfig.General.PickPadding) ?? 1;
                 const enableWboit = this.config.get(PluginConfig.General.EnableWboit) || false;
-                (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, { antialias, preserveDrawingBuffer, pixelScale, pickScale, enableWboit });
+                const preferWebGl1 = this.config.get(PluginConfig.General.PreferWebGl1) || false;
+                (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, { antialias, preserveDrawingBuffer, pixelScale, pickScale, enableWboit, preferWebGl1 });
+                (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, { antialias, preserveDrawingBuffer, pixelScale, pickScale, pickPadding, enableWboit });
             }
             (this.canvas3d as Canvas3D) = Canvas3D.create(this.canvas3dContext!);
             this.canvas3dInit.next(true);

+ 9 - 4
src/mol-repr/representation.ts

@@ -65,12 +65,16 @@ export namespace RepresentationProvider {
 
 export type AnyRepresentationProvider = RepresentationProvider<any, {}, Representation.State>
 
-const EmptyRepresentationProvider = {
+export const EmptyRepresentationProvider: RepresentationProvider = {
+    name: '',
     label: '',
     description: '',
     factory: () => Representation.Empty,
     getParams: () => ({}),
-    defaultValues: {}
+    defaultValues: {},
+    defaultColorTheme: ColorTheme.EmptyProvider,
+    defaultSizeTheme: SizeTheme.EmptyProvider,
+    isApplicable: () => true
 };
 
 function getTypes(list: { name: string, provider: RepresentationProvider<any, any, any> }[]) {
@@ -114,7 +118,7 @@ export class RepresentationRegistry<D, S extends Representation.State> {
     }
 
     get<P extends PD.Params>(name: string): RepresentationProvider<D, P, S> {
-        return this._map.get(name) || EmptyRepresentationProvider as unknown as RepresentationProvider<D, P, S>;
+        return this._map.get(name) || EmptyRepresentationProvider;
     }
 
     get list() {
@@ -226,7 +230,7 @@ namespace Representation {
         let version = 0;
         const updated = new Subject<number>();
         const currentState = stateBuilder.create();
-        const currentTheme = Theme.createEmpty();
+        let currentTheme = Theme.createEmpty();
 
         let currentParams: P;
         let currentProps: PD.Values<P>;
@@ -314,6 +318,7 @@ namespace Representation {
                 }
             },
             setTheme: (theme: Theme) => {
+                currentTheme = theme;
                 for (let i = 0, il = reprList.length; i < il; ++i) {
                     reprList[i].setTheme(theme);
                 }

+ 45 - 57
src/mol-repr/shape/loci/orientation.ts

@@ -1,10 +1,9 @@
 /**
- * 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 Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Loci } from '../../../mol-model/loci';
 import { RuntimeContext } from '../../../mol-task';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { ColorNames } from '../../../mol-util/color/names';
@@ -13,21 +12,23 @@ import { Representation, RepresentationParamsGetter, RepresentationContext } fro
 import { Shape } from '../../../mol-model/shape';
 import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
 import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
-import { lociLabel } from '../../../mol-theme/label';
+import { structureElementLociLabelMany } from '../../../mol-theme/label';
 import { addAxes } from '../../../mol-geo/geometry/mesh/builder/axes';
 import { addOrientedBox } from '../../../mol-geo/geometry/mesh/builder/box';
 import { addEllipsoid } from '../../../mol-geo/geometry/mesh/builder/ellipsoid';
 import { Axes3D } from '../../../mol-math/geometry';
 import { Vec3 } from '../../../mol-math/linear-algebra';
 import { MarkerActions } from '../../../mol-util/marker-action';
+import { StructureElement } from '../../../mol-model/structure';
 
 export interface OrientationData {
-    locis: Loci[]
+    locis: StructureElement.Loci[]
 }
 
 const SharedParams = {
     color: PD.Color(ColorNames.orange),
-    scale: PD.Numeric(2, { min: 0.1, max: 10, step: 0.1 })
+    scaleFactor: PD.Numeric(1, { min: 0.1, max: 10, step: 0.1 }),
+    radiusScale: PD.Numeric(2, { min: 0.1, max: 10, step: 0.1 })
 };
 
 const AxesParams = {
@@ -57,97 +58,84 @@ const OrientationVisuals = {
 export const OrientationParams = {
     ...AxesParams,
     ...BoxParams,
+    ...EllipsoidParams,
     visuals: PD.MultiSelect(['box'], PD.objectToOptions(OrientationVisuals)),
-    color: PD.Color(ColorNames.orange),
-    scale: PD.Numeric(2, { min: 0.1, max: 5, step: 0.1 })
 };
 export type OrientationParams = typeof OrientationParams
 export type OrientationProps = PD.Values<OrientationParams>
 
 //
 
-function orientationLabel(loci: Loci) {
-    const label = lociLabel(loci, { countsOnly: true });
+function getAxesName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
     return `Principal Axes of ${label}`;
 }
 
-function getOrientationName(data: OrientationData) {
-    return data.locis.length === 1 ? orientationLabel(data.locis[0]) : `${data.locis.length} Orientations`;
-}
-
-//
-
 function buildAxesMesh(data: OrientationData, props: OrientationProps, mesh?: Mesh): Mesh {
     const state = MeshBuilder.createState(256, 128, mesh);
-    for (let i = 0, il = data.locis.length; i < il; ++i) {
-        const principalAxes = Loci.getPrincipalAxes(data.locis[i]);
-        if (principalAxes) {
-            state.currentGroup = i;
-            addAxes(state, principalAxes.momentsAxes, props.scale, 2, 20);
-        }
-    }
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+    Axes3D.scale(principalAxes.momentsAxes, principalAxes.momentsAxes, props.scaleFactor);
+
+    state.currentGroup = 0;
+    addAxes(state, principalAxes.momentsAxes, props.radiusScale, 2, 20);
     return MeshBuilder.getMesh(state);
 }
 
 function getAxesShape(ctx: RuntimeContext, data: OrientationData, props: OrientationProps, shape?: Shape<Mesh>) {
     const mesh = buildAxesMesh(data, props, shape && shape.geometry);
-    const name = getOrientationName(data);
-    const getLabel = function (groupId: number) {
-        return orientationLabel(data.locis[groupId]);
-    };
-    return Shape.create(name, data, mesh, () => props.color, () => 1, getLabel);
+    const name = getAxesName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
 }
 
 //
 
+function getBoxName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
+    return `Oriented Box of ${label}`;
+}
+
 function buildBoxMesh(data: OrientationData, props: OrientationProps, mesh?: Mesh): Mesh {
     const state = MeshBuilder.createState(256, 128, mesh);
-    for (let i = 0, il = data.locis.length; i < il; ++i) {
-        const principalAxes = Loci.getPrincipalAxes(data.locis[i]);
-        if (principalAxes) {
-            state.currentGroup = i;
-            addOrientedBox(state, principalAxes.boxAxes, props.scale, 2, 20);
-        }
-    }
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+    Axes3D.scale(principalAxes.boxAxes, principalAxes.boxAxes, props.scaleFactor);
+
+    state.currentGroup = 0;
+    addOrientedBox(state, principalAxes.boxAxes, props.radiusScale, 2, 20);
     return MeshBuilder.getMesh(state);
 }
 
 function getBoxShape(ctx: RuntimeContext, data: OrientationData, props: OrientationProps, shape?: Shape<Mesh>) {
     const mesh = buildBoxMesh(data, props, shape && shape.geometry);
-    const name = getOrientationName(data);
-    const getLabel = function (groupId: number) {
-        return orientationLabel(data.locis[groupId]);
-    };
-    return Shape.create(name, data, mesh, () => props.color, () => 1, getLabel);
+    const name = getBoxName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
 }
 
 //
 
+function getEllipsoidName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
+    return `Oriented Ellipsoid of ${label}`;
+}
+
 function buildEllipsoidMesh(data: OrientationData, props: OrientationProps, mesh?: Mesh): Mesh {
     const state = MeshBuilder.createState(256, 128, mesh);
-    for (let i = 0, il = data.locis.length; i < il; ++i) {
-        const principalAxes = Loci.getPrincipalAxes(data.locis[i]);
-        if (principalAxes) {
-            const axes = principalAxes.boxAxes;
-            const { origin, dirA, dirB } = axes;
-            const size = Axes3D.size(Vec3(), axes);
-            Vec3.scale(size, size, 0.5);
-            const radiusScale = Vec3.create(size[2], size[1], size[0]);
-
-            state.currentGroup = i;
-            addEllipsoid(state, origin, dirA, dirB, radiusScale, 2);
-        }
-    }
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+
+    const axes = principalAxes.boxAxes;
+    const { origin, dirA, dirB } = axes;
+    const size = Axes3D.size(Vec3(), axes);
+    Vec3.scale(size, size, 0.5 * props.scaleFactor);
+    const radiusScale = Vec3.create(size[2], size[1], size[0]);
+
+    state.currentGroup = 0;
+    addEllipsoid(state, origin, dirA, dirB, radiusScale, 2);
     return MeshBuilder.getMesh(state);
 }
 
 function getEllipsoidShape(ctx: RuntimeContext, data: OrientationData, props: OrientationProps, shape?: Shape<Mesh>) {
     const mesh = buildEllipsoidMesh(data, props, shape && shape.geometry);
-    const name = getOrientationName(data);
-    const getLabel = function (groupId: number) {
-        return orientationLabel(data.locis[groupId]);
-    };
-    return Shape.create(name, data, mesh, () => props.color, () => 1, getLabel);
+    const name = getEllipsoidName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
 }
 
 //

+ 84 - 0
src/mol-repr/shape/loci/plane.ts

@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { RuntimeContext } from '../../../mol-task';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { ColorNames } from '../../../mol-util/color/names';
+import { ShapeRepresentation } from '../representation';
+import { Representation, RepresentationParamsGetter, RepresentationContext } from '../../representation';
+import { Shape } from '../../../mol-model/shape';
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
+import { structureElementLociLabelMany } from '../../../mol-theme/label';
+import { Mat4, Vec3 } from '../../../mol-math/linear-algebra';
+import { MarkerActions } from '../../../mol-util/marker-action';
+import { Plane } from '../../../mol-geo/primitive/plane';
+import { StructureElement } from '../../../mol-model/structure';
+import { Axes3D } from '../../../mol-math/geometry';
+
+export interface PlaneData {
+    locis: StructureElement.Loci[]
+}
+
+const _PlaneParams = {
+    ...Mesh.Params,
+    color: PD.Color(ColorNames.orange),
+    scaleFactor: PD.Numeric(1, { min: 0.1, max: 10, step: 0.1 }),
+};
+type _PlaneParams = typeof _PlaneParams
+
+const PlaneVisuals = {
+    'plane': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<PlaneData, _PlaneParams>) => ShapeRepresentation(getPlaneShape, Mesh.Utils),
+};
+
+export const PlaneParams = {
+    ..._PlaneParams,
+    visuals: PD.MultiSelect(['plane'], PD.objectToOptions(PlaneVisuals)),
+};
+export type PlaneParams = typeof PlaneParams
+export type PlaneProps = PD.Values<PlaneParams>
+
+//
+
+function getPlaneName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
+    return `Best Fit Plane of ${label}`;
+}
+
+const tmpMat = Mat4();
+const tmpV = Vec3();
+function buildPlaneMesh(data: PlaneData, props: PlaneProps, mesh?: Mesh): Mesh {
+    const state = MeshBuilder.createState(256, 128, mesh);
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+    const axes = principalAxes.boxAxes;
+    const plane = Plane();
+
+    Vec3.add(tmpV, axes.origin, axes.dirC);
+    Mat4.targetTo(tmpMat, tmpV, axes.origin, axes.dirB);
+    Mat4.scale(tmpMat, tmpMat, Axes3D.size(tmpV, axes));
+    Mat4.scaleUniformly(tmpMat, tmpMat, props.scaleFactor);
+    Mat4.setTranslation(tmpMat, axes.origin);
+
+    state.currentGroup = 0;
+    MeshBuilder.addPrimitive(state, tmpMat, plane);
+    MeshBuilder.addPrimitiveFlipped(state, tmpMat, plane);
+    return MeshBuilder.getMesh(state);
+}
+
+function getPlaneShape(ctx: RuntimeContext, data: PlaneData, props: PlaneProps, shape?: Shape<Mesh>) {
+    const mesh = buildPlaneMesh(data, props, shape && shape.geometry);
+    const name = getPlaneName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
+}
+
+//
+
+export type PlaneRepresentation = Representation<PlaneData, PlaneParams>
+export function PlaneRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<PlaneData, PlaneParams>): PlaneRepresentation {
+    const repr = Representation.createMulti('Plane', ctx, getParams, Representation.StateBuilder, PlaneVisuals as unknown as Representation.Def<PlaneData, PlaneParams>);
+    repr.setState({ markerActions: MarkerActions.Highlighting });
+    return repr;
+}

+ 14 - 5
src/mol-repr/structure/representation/line.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -14,23 +14,32 @@ import { Representation, RepresentationParamsGetter, RepresentationContext } fro
 import { ThemeRegistryContext } from '../../../mol-theme/theme';
 import { Structure } from '../../../mol-model/structure';
 import { getUnitKindsParam } from '../params';
+import { ElementPointParams, ElementPointVisual } from '../visual/element-point';
+import { ElementCrossParams, ElementCrossVisual } from '../visual/element-cross';
 
 const LineVisuals = {
     'intra-bond': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, IntraUnitBondLineParams>) => UnitsRepresentation('Intra-unit bond line', ctx, getParams, IntraUnitBondLineVisual),
     'inter-bond': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InterUnitBondLineParams>) => ComplexRepresentation('Inter-unit bond line', ctx, getParams, InterUnitBondLineVisual),
+    'element-point': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, ElementPointParams>) => UnitsRepresentation('Points', ctx, getParams, ElementPointVisual),
+    'element-cross': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, ElementCrossParams>) => UnitsRepresentation('Crosses', ctx, getParams, ElementCrossVisual),
 };
 
 export const LineParams = {
     ...IntraUnitBondLineParams,
     ...InterUnitBondLineParams,
+    ...ElementPointParams,
+    ...ElementCrossParams,
+    multipleBonds: PD.Select('offset', PD.arrayToOptions(['off', 'symmetric', 'offset'] as const)),
     includeParent: PD.Boolean(false),
-    sizeFactor: PD.Numeric(1.5, { min: 0.01, max: 10, step: 0.01 }),
+    sizeFactor: PD.Numeric(3, { min: 0.01, max: 10, step: 0.01 }),
     unitKinds: getUnitKindsParam(['atomic']),
-    visuals: PD.MultiSelect(['intra-bond', 'inter-bond'], PD.objectToOptions(LineVisuals))
+    visuals: PD.MultiSelect(['intra-bond', 'inter-bond', 'element-point', 'element-cross'], PD.objectToOptions(LineVisuals))
 };
 export type LineParams = typeof LineParams
 export function getLineParams(ctx: ThemeRegistryContext, structure: Structure) {
-    return PD.clone(LineParams);
+    const params = PD.clone(LineParams);
+    params.pointStyle.defaultValue = 'circle';
+    return params;
 }
 
 export type LineRepresentation = StructureRepresentation<LineParams>
@@ -41,7 +50,7 @@ export function LineRepresentation(ctx: RepresentationContext, getParams: Repres
 export const LineRepresentationProvider = StructureRepresentationProvider({
     name: 'line',
     label: 'Line',
-    description: 'Displays bonds as lines.',
+    description: 'Displays bonds as lines and atoms as points or croses.',
     factory: LineRepresentation,
     getParams: getLineParams,
     defaultValues: PD.getDefaultValues(LineParams),

+ 2 - 2
src/mol-repr/structure/representation/point.ts

@@ -32,11 +32,11 @@ export function PointRepresentation(ctx: RepresentationContext, getParams: Repre
 export const PointRepresentationProvider = StructureRepresentationProvider({
     name: 'point',
     label: 'Point',
-    description: 'Displays elements (atoms, coarse spheres) as spheres.',
+    description: 'Displays elements (atoms, coarse spheres) as points.',
     factory: PointRepresentation,
     getParams: getPointParams,
     defaultValues: PD.getDefaultValues(PointParams),
     defaultColorTheme: { name: 'element-symbol' },
-    defaultSizeTheme: { name: 'physical' },
+    defaultSizeTheme: { name: 'uniform' },
     isApplicable: (structure: Structure) => structure.elementCount > 0
 });

+ 20 - 7
src/mol-repr/structure/visual/bond-inter-unit-cylinder.ts

@@ -40,7 +40,10 @@ function getInterUnitBondCylinderBuilderProps(structure: Structure, theme: Theme
 
     const bonds = structure.interUnitBonds;
     const { edgeCount, edges } = bonds;
-    const { sizeFactor, sizeAspectRatio, adjustCylinderLength, aromaticBonds } = props;
+    const { sizeFactor, sizeAspectRatio, adjustCylinderLength, aromaticBonds, multipleBonds } = props;
+
+    const mbOff = multipleBonds === 'off';
+    const mbSymmetric = multipleBonds === 'symmetric';
 
     const delta = Vec3();
 
@@ -136,14 +139,16 @@ function getInterUnitBondCylinderBuilderProps(structure: Structure, theme: Theme
                 // show metallic coordinations and hydrogen bonds with dashed cylinders
                 return LinkStyle.Dashed;
             } else if (o === 3) {
-                return LinkStyle.Triple;
+                return mbOff ? LinkStyle.Solid :
+                    mbSymmetric ? LinkStyle.Triple :
+                        LinkStyle.OffsetTriple;
             } else if (aromaticBonds && BondType.is(f, BondType.Flag.Aromatic)) {
                 return LinkStyle.Aromatic;
-            } else if (o === 2) {
-                return LinkStyle.Double;
             }
 
-            return LinkStyle.Solid;
+            return (o !== 2 || mbOff) ? LinkStyle.Solid :
+                mbSymmetric ? LinkStyle.Double :
+                    LinkStyle.OffsetDouble;
         },
         radius: (edgeIndex: number) => {
             return radius(edgeIndex) * sizeAspectRatio;
@@ -210,13 +215,17 @@ export function InterUnitBondCylinderImpostorVisual(materialId: number): Complex
                 newProps.linkSpacing !== currentProps.linkSpacing ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.linkCap !== currentProps.linkCap ||
+                newProps.aromaticScale !== currentProps.aromaticScale ||
+                newProps.aromaticSpacing !== currentProps.aromaticSpacing ||
+                newProps.aromaticDashCount !== currentProps.aromaticDashCount ||
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
                 newProps.stubCap !== currentProps.stubCap ||
                 !arrayEqual(newProps.includeTypes, currentProps.includeTypes) ||
                 !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes) ||
-                newProps.adjustCylinderLength !== currentProps.adjustCylinderLength
+                newProps.adjustCylinderLength !== currentProps.adjustCylinderLength ||
+                newProps.multipleBonds !== currentProps.multipleBonds
             );
 
             if (newStructure.interUnitBonds !== currentStructure.interUnitBonds) {
@@ -248,13 +257,17 @@ export function InterUnitBondCylinderMeshVisual(materialId: number): ComplexVisu
                 newProps.linkSpacing !== currentProps.linkSpacing ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.linkCap !== currentProps.linkCap ||
+                newProps.aromaticScale !== currentProps.aromaticScale ||
+                newProps.aromaticSpacing !== currentProps.aromaticSpacing ||
+                newProps.aromaticDashCount !== currentProps.aromaticDashCount ||
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
                 newProps.stubCap !== currentProps.stubCap ||
                 !arrayEqual(newProps.includeTypes, currentProps.includeTypes) ||
                 !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes) ||
-                newProps.adjustCylinderLength !== currentProps.adjustCylinderLength
+                newProps.adjustCylinderLength !== currentProps.adjustCylinderLength ||
+                newProps.multipleBonds !== currentProps.multipleBonds
             );
 
             if (newStructure.interUnitBonds !== currentStructure.interUnitBonds) {

+ 14 - 6
src/mol-repr/structure/visual/bond-inter-unit-line.ts

@@ -32,10 +32,14 @@ function setRefPosition(pos: Vec3, structure: Structure, unit: Unit.Atomic, inde
 function createInterUnitBondLines(ctx: VisualContext, structure: Structure, theme: Theme, props: PD.Values<InterUnitBondLineParams>, lines?: Lines) {
     const bonds = structure.interUnitBonds;
     const { edgeCount, edges } = bonds;
-    const { sizeFactor, aromaticBonds } = props;
 
     if (!edgeCount) return Lines.createEmpty(lines);
 
+    const { sizeFactor, aromaticBonds, multipleBonds } = props;
+
+    const mbOff = multipleBonds === 'off';
+    const mbSymmetric = multipleBonds === 'symmetric';
+
     const ref = Vec3();
     const loc = StructureElement.Location.create();
 
@@ -74,14 +78,16 @@ function createInterUnitBondLines(ctx: VisualContext, structure: Structure, them
                 // show metallic coordinations and hydrogen bonds with dashed cylinders
                 return LinkStyle.Dashed;
             } else if (o === 3) {
-                return LinkStyle.Triple;
+                return mbOff ? LinkStyle.Solid :
+                    mbSymmetric ? LinkStyle.Triple :
+                        LinkStyle.OffsetTriple;
             } else if (aromaticBonds && BondType.is(f, BondType.Flag.Aromatic)) {
                 return LinkStyle.Aromatic;
-            } else if (o === 2) {
-                return LinkStyle.Double;
             }
 
-            return LinkStyle.Solid;
+            return (o !== 2 || mbOff) ? LinkStyle.Solid :
+                mbSymmetric ? LinkStyle.Double :
+                    LinkStyle.OffsetDouble;
         },
         radius: (edgeIndex: number) => {
             const b = edges[edgeIndex];
@@ -125,10 +131,12 @@ export function InterUnitBondLineVisual(materialId: number): ComplexVisual<Inter
                 newProps.sizeFactor !== currentProps.sizeFactor ||
                 newProps.linkScale !== currentProps.linkScale ||
                 newProps.linkSpacing !== currentProps.linkSpacing ||
+                newProps.aromaticDashCount !== currentProps.aromaticDashCount ||
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 !arrayEqual(newProps.includeTypes, currentProps.includeTypes) ||
-                !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes)
+                !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes) ||
+                newProps.multipleBonds !== currentProps.multipleBonds
             );
 
             if (newStructure.interUnitBonds !== currentStructure.interUnitBonds) {

+ 20 - 5
src/mol-repr/structure/visual/bond-intra-unit-cylinder.ts

@@ -33,7 +33,10 @@ function getIntraUnitBondCylinderBuilderProps(unit: Unit.Atomic, structure: Stru
     const bonds = unit.bonds;
     const { edgeCount, a, b, edgeProps, offset } = bonds;
     const { order: _order, flags: _flags } = edgeProps;
-    const { sizeFactor, sizeAspectRatio, adjustCylinderLength, aromaticBonds, includeTypes, excludeTypes } = props;
+    const { sizeFactor, sizeAspectRatio, adjustCylinderLength, aromaticBonds, includeTypes, excludeTypes, multipleBonds } = props;
+
+    const mbOff = multipleBonds === 'off';
+    const mbSymmetric = multipleBonds === 'symmetric';
 
     const include = BondType.fromNames(includeTypes);
     const exclude = BondType.fromNames(excludeTypes);
@@ -130,7 +133,9 @@ function getIntraUnitBondCylinderBuilderProps(unit: Unit.Atomic, structure: Stru
                 // show metallic coordinations and hydrogen bonds with dashed cylinders
                 return LinkStyle.Dashed;
             } else if (o === 3) {
-                return LinkStyle.Triple;
+                return mbOff ? LinkStyle.Solid :
+                    mbSymmetric ? LinkStyle.Triple :
+                        LinkStyle.OffsetTriple;
             } else if (aromaticBonds) {
                 const aI = a[edgeIndex], bI = b[edgeIndex];
                 const aR = elementAromaticRingIndices.get(aI);
@@ -146,7 +151,9 @@ function getIntraUnitBondCylinderBuilderProps(unit: Unit.Atomic, structure: Stru
                 }
             }
 
-            return o === 2 ? LinkStyle.Double : LinkStyle.Solid;
+            return (o !== 2 || mbOff) ? LinkStyle.Solid :
+                mbSymmetric ? LinkStyle.Double :
+                    LinkStyle.OffsetDouble;
         },
         radius: (edgeIndex: number) => {
             return radius(edgeIndex) * sizeAspectRatio;
@@ -221,6 +228,9 @@ export function IntraUnitBondCylinderImpostorVisual(materialId: number): UnitsVi
                 newProps.linkSpacing !== currentProps.linkSpacing ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.linkCap !== currentProps.linkCap ||
+                newProps.aromaticScale !== currentProps.aromaticScale ||
+                newProps.aromaticSpacing !== currentProps.aromaticSpacing ||
+                newProps.aromaticDashCount !== currentProps.aromaticDashCount ||
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
@@ -228,7 +238,8 @@ export function IntraUnitBondCylinderImpostorVisual(materialId: number): UnitsVi
                 !arrayEqual(newProps.includeTypes, currentProps.includeTypes) ||
                 !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes) ||
                 newProps.adjustCylinderLength !== currentProps.adjustCylinderLength ||
-                newProps.aromaticBonds !== currentProps.aromaticBonds
+                newProps.aromaticBonds !== currentProps.aromaticBonds ||
+                newProps.multipleBonds !== currentProps.multipleBonds
             );
 
             const newUnit = newStructureGroup.group.units[0];
@@ -264,6 +275,9 @@ export function IntraUnitBondCylinderMeshVisual(materialId: number): UnitsVisual
                 newProps.linkSpacing !== currentProps.linkSpacing ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.linkCap !== currentProps.linkCap ||
+                newProps.aromaticScale !== currentProps.aromaticScale ||
+                newProps.aromaticSpacing !== currentProps.aromaticSpacing ||
+                newProps.aromaticDashCount !== currentProps.aromaticDashCount ||
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
@@ -271,7 +285,8 @@ export function IntraUnitBondCylinderMeshVisual(materialId: number): UnitsVisual
                 !arrayEqual(newProps.includeTypes, currentProps.includeTypes) ||
                 !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes) ||
                 newProps.adjustCylinderLength !== currentProps.adjustCylinderLength ||
-                newProps.aromaticBonds !== currentProps.aromaticBonds
+                newProps.aromaticBonds !== currentProps.aromaticBonds ||
+                newProps.multipleBonds !== currentProps.multipleBonds
             );
 
             const newUnit = newStructureGroup.group.units[0];

+ 13 - 4
src/mol-repr/structure/visual/bond-intra-unit-line.ts

@@ -39,7 +39,10 @@ function createIntraUnitBondLines(ctx: VisualContext, unit: Unit, structure: Str
     if (!edgeCount) return Lines.createEmpty(lines);
 
     const { order: _order, flags: _flags } = edgeProps;
-    const { sizeFactor, aromaticBonds, includeTypes, excludeTypes } = props;
+    const { sizeFactor, aromaticBonds, includeTypes, excludeTypes, multipleBonds } = props;
+
+    const mbOff = multipleBonds === 'off';
+    const mbSymmetric = multipleBonds === 'symmetric';
 
     const include = BondType.fromNames(includeTypes);
     const exclude = BondType.fromNames(excludeTypes);
@@ -91,7 +94,9 @@ function createIntraUnitBondLines(ctx: VisualContext, unit: Unit, structure: Str
                 // show metallic coordinations and hydrogen bonds with dashed cylinders
                 return LinkStyle.Dashed;
             } else if (o === 3) {
-                return LinkStyle.Triple;
+                return mbOff ? LinkStyle.Solid :
+                    mbSymmetric ? LinkStyle.Triple :
+                        LinkStyle.OffsetTriple;
             } else if (aromaticBonds) {
                 const aI = a[edgeIndex], bI = b[edgeIndex];
                 const aR = elementAromaticRingIndices.get(aI);
@@ -107,7 +112,9 @@ function createIntraUnitBondLines(ctx: VisualContext, unit: Unit, structure: Str
                 }
             }
 
-            return o === 2 ? LinkStyle.Double : LinkStyle.Solid;
+            return (o !== 2 || mbOff) ? LinkStyle.Solid :
+                mbSymmetric ? LinkStyle.Double :
+                    LinkStyle.OffsetDouble;
         },
         radius: (edgeIndex: number) => {
             location.element = elements[a[edgeIndex]];
@@ -146,11 +153,13 @@ export function IntraUnitBondLineVisual(materialId: number): UnitsVisual<IntraUn
                 newProps.sizeFactor !== currentProps.sizeFactor ||
                 newProps.linkScale !== currentProps.linkScale ||
                 newProps.linkSpacing !== currentProps.linkSpacing ||
+                newProps.aromaticDashCount !== currentProps.aromaticDashCount ||
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 !arrayEqual(newProps.includeTypes, currentProps.includeTypes) ||
                 !arrayEqual(newProps.excludeTypes, currentProps.excludeTypes) ||
-                newProps.aromaticBonds !== currentProps.aromaticBonds
+                newProps.aromaticBonds !== currentProps.aromaticBonds ||
+                newProps.multipleBonds !== currentProps.multipleBonds
             );
 
             const newUnit = newStructureGroup.group.units[0];

+ 94 - 0
src/mol-repr/structure/visual/element-cross.ts

@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { UnitsVisual, UnitsLinesParams, UnitsLinesVisual } from '../units-visual';
+import { VisualContext } from '../../visual';
+import { Unit, Structure, StructureElement } from '../../../mol-model/structure';
+import { Theme } from '../../../mol-theme/theme';
+import { Vec3 } from '../../../mol-math/linear-algebra';
+import { ElementIterator, getElementLoci, eachElement, makeElementIgnoreTest } from './util/element';
+import { VisualUpdateState } from '../../util';
+import { Sphere3D } from '../../../mol-math/geometry';
+import { Lines } from '../../../mol-geo/geometry/lines/lines';
+import { LinesBuilder } from '../../../mol-geo/geometry/lines/lines-builder';
+import { bondCount } from '../../../mol-model-props/computed/chemistry/util';
+
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3scaleAndAdd = Vec3.scaleAndAdd;
+const v3unitX = Vec3.unitX;
+const v3unitY = Vec3.unitY;
+const v3unitZ = Vec3.unitZ;
+
+export const ElementCrossParams = {
+    ...UnitsLinesParams,
+    lineSizeAttenuation: PD.Boolean(false),
+    ignoreHydrogens: PD.Boolean(false),
+    traceOnly: PD.Boolean(false),
+    crosses: PD.Select('lone', PD.arrayToOptions(['lone', 'all'] as const)),
+    crossSize: PD.Numeric(0.35, { min: 0, max: 2, step: 0.01 }),
+};
+export type ElementCrossParams = typeof ElementCrossParams
+
+export function createElementCross(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: PD.Values<ElementCrossParams>, lines: Lines) {
+    const { child } = structure;
+    if (child && !child.unitMap.get(unit.id)) return Lines.createEmpty(lines);
+
+    const elements = unit.elements;
+    const n = elements.length;
+    const builder = LinesBuilder.create(n, n / 10, lines);
+
+    const p = Vec3();
+    const s = Vec3();
+    const e = Vec3();
+
+    const pos = unit.conformation.invariantPosition;
+    const ignore = makeElementIgnoreTest(structure, unit, props);
+
+    const r = props.crossSize / 2;
+    const lone = props.crosses === 'lone';
+
+    for (let i = 0 as StructureElement.UnitIndex; i < n; ++i) {
+        if (ignore && ignore(elements[i])) continue;
+        if (lone && Unit.isAtomic(unit) && bondCount(structure, unit, i) !== 0) continue;
+
+        pos(elements[i], p);
+        v3scaleAndAdd(s, p, v3unitX, r);
+        v3scaleAndAdd(e, p, v3unitX, -r);
+        builder.add(s[0], s[1], s[2], e[0], e[1], e[2], i);
+        v3scaleAndAdd(s, p, v3unitY, r);
+        v3scaleAndAdd(e, p, v3unitY, -r);
+        builder.add(s[0], s[1], s[2], e[0], e[1], e[2], i);
+        v3scaleAndAdd(s, p, v3unitZ, r);
+        v3scaleAndAdd(e, p, v3unitZ, -r);
+        builder.add(s[0], s[1], s[2], e[0], e[1], e[2], i);
+    }
+
+    const l = builder.getLines();
+
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    l.setBoundingSphere(sphere);
+
+    return l;
+}
+
+export function ElementCrossVisual(materialId: number): UnitsVisual<ElementCrossParams> {
+    return UnitsLinesVisual<ElementCrossParams>({
+        defaultProps: PD.getDefaultValues(ElementCrossParams),
+        createGeometry: createElementCross,
+        createLocationIterator: ElementIterator.fromGroup,
+        getLoci: getElementLoci,
+        eachLocation: eachElement,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<ElementCrossParams>, currentProps: PD.Values<ElementCrossParams>) => {
+            state.createGeometry = (
+                newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
+                newProps.traceOnly !== currentProps.traceOnly ||
+                newProps.crosses !== currentProps.crosses ||
+                newProps.crossSize !== currentProps.crossSize
+            );
+        }
+    }, materialId);
+}

+ 1 - 1
src/mol-repr/structure/visual/element-point.ts

@@ -18,7 +18,7 @@ import { Sphere3D } from '../../../mol-math/geometry';
 
 export const ElementPointParams = {
     ...UnitsPointsParams,
-    pointSizeAttenuation: PD.Boolean(true),
+    pointSizeAttenuation: PD.Boolean(false),
     ignoreHydrogens: PD.Boolean(false),
     traceOnly: PD.Boolean(false),
 };

+ 2 - 1
src/mol-repr/structure/visual/util/bond.ts

@@ -20,6 +20,7 @@ export const BondParams = {
     excludeTypes: PD.MultiSelect([] as BondType.Names[], PD.objectToOptions(BondType.Names)),
     ignoreHydrogens: PD.Boolean(false),
     aromaticBonds: PD.Boolean(false, { description: 'Display aromatic bonds with dashes' }),
+    multipleBonds: PD.Select('symmetric', PD.arrayToOptions(['off', 'symmetric', 'offset'] as const)),
 };
 export const DefaultBondProps = PD.getDefaultValues(BondParams);
 export type BondProps = typeof DefaultBondProps
@@ -27,7 +28,7 @@ export type BondProps = typeof DefaultBondProps
 export const BondCylinderParams = {
     ...LinkCylinderParams,
     ...BondParams,
-    adjustCylinderLength: PD.Boolean(true, { description: 'Shorten cylinders to reduce overlap with spheres.' })
+    adjustCylinderLength: PD.Boolean(false, { description: 'Shorten cylinders to reduce overlap with spheres. Useful for for transparent bonds. Not working well with aromatic bonds.' })
 };
 export const DefaultBondCylinderProps = PD.getDefaultValues(BondCylinderParams);
 export type BondCylinderProps = typeof DefaultBondCylinderProps

+ 96 - 32
src/mol-repr/structure/visual/util/link.ts

@@ -21,6 +21,9 @@ export const LinkCylinderParams = {
     linkScale: PD.Numeric(0.45, { min: 0, max: 1, step: 0.01 }),
     linkSpacing: PD.Numeric(1, { min: 0, max: 2, step: 0.01 }),
     linkCap: PD.Boolean(false),
+    aromaticScale: PD.Numeric(0.3, { min: 0, max: 1, step: 0.01 }),
+    aromaticSpacing: PD.Numeric(1.5, { min: 0, max: 3, step: 0.01 }),
+    aromaticDashCount: PD.Numeric(2, { min: 2, max: 6, step: 2 }),
     dashCount: PD.Numeric(4, { min: 2, max: 10, step: 2 }),
     dashScale: PD.Numeric(0.8, { min: 0, max: 2, step: 0.1 }),
     dashCap: PD.Boolean(true),
@@ -33,6 +36,7 @@ export type LinkCylinderProps = typeof DefaultLinkCylinderProps
 export const LinkLineParams = {
     linkScale: PD.Numeric(0.5, { min: 0, max: 1, step: 0.1 }),
     linkSpacing: PD.Numeric(0.1, { min: 0, max: 2, step: 0.01 }),
+    aromaticDashCount: PD.Numeric(2, { min: 2, max: 6, step: 2 }),
     dashCount: PD.Numeric(4, { min: 2, max: 10, step: 2 }),
 };
 export const DefaultLinkLineProps = PD.getDefaultValues(LinkLineParams);
@@ -83,10 +87,12 @@ export const enum LinkStyle {
     Solid = 0,
     Dashed = 1,
     Double = 2,
-    Triple = 3,
-    Disk = 4,
-    Aromatic = 5,
-    MirroredAromatic = 6,
+    OffsetDouble = 3,
+    Triple = 4,
+    OffsetTriple = 5,
+    Disk = 6,
+    Aromatic = 7,
+    MirroredAromatic = 8,
 }
 
 // avoiding namespace lookup improved performance in Chrome (Aug 2020)
@@ -105,7 +111,7 @@ export function createLinkCylinderMesh(ctx: VisualContext, linkBuilder: LinkBuil
 
     if (!linkCount) return Mesh.createEmpty(mesh);
 
-    const { linkScale, linkSpacing, radialSegments, linkCap, dashCount, dashScale, dashCap, stubCap } = props;
+    const { linkScale, linkSpacing, radialSegments, linkCap, aromaticScale, aromaticSpacing, aromaticDashCount, dashCount, dashScale, dashCap, stubCap } = props;
 
     const vertexCountEstimate = radialSegments * 2 * linkCount * 2;
     const builderState = MeshBuilder.createState(vertexCountEstimate, vertexCountEstimate / 4, mesh);
@@ -128,14 +134,15 @@ export function createLinkCylinderMesh(ctx: VisualContext, linkBuilder: LinkBuil
 
         position(va, vb, edgeIndex);
         v3sub(tmpV12, vb, va);
+        const dirFlag = v3dot(tmpV12, up) > 0;
 
         const linkRadius = radius(edgeIndex);
         const linkStyle = style ? style(edgeIndex) : LinkStyle.Solid;
         const linkStub = stubCap && (stub ? stub(edgeIndex) : false);
-        const [topCap, bottomCap] = (v3dot(tmpV12, up) > 0) ? [linkStub, linkCap] : [linkCap, linkStub];
+        const [topCap, bottomCap] = dirFlag ? [linkStub, linkCap] : [linkCap, linkStub];
         builderState.currentGroup = edgeIndex;
 
-        const aromaticOffsetFactor = 4.5;
+        const aromaticSegmentCount = aromaticDashCount + 1;
 
         if (linkStyle === LinkStyle.Solid) {
             cylinderProps.radiusTop = cylinderProps.radiusBottom = linkRadius;
@@ -148,9 +155,9 @@ export function createLinkCylinderMesh(ctx: VisualContext, linkBuilder: LinkBuil
             cylinderProps.topCap = cylinderProps.bottomCap = dashCap;
 
             addFixedCountDashedCylinder(builderState, va, vb, 0.5, segmentCount, cylinderProps);
-        } else if (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
-            const order = linkStyle === LinkStyle.Double ? 2 :
-                linkStyle === LinkStyle.Triple ? 3 : 1.5;
+        } else if (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.OffsetDouble || linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.OffsetTriple || linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
+            const order = (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.OffsetDouble) ? 2 :
+                (linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.OffsetTriple) ? 3 : 1.5;
             const multiRadius = linkRadius * (linkScale / (0.5 * order));
             const absOffset = (linkRadius - multiRadius) * linkSpacing;
 
@@ -163,18 +170,49 @@ export function createLinkCylinderMesh(ctx: VisualContext, linkBuilder: LinkBuil
                 cylinderProps.radiusTop = cylinderProps.radiusBottom = linkRadius;
                 addCylinder(builderState, va, vb, 0.5, cylinderProps);
 
-                cylinderProps.radiusTop = cylinderProps.radiusBottom = linkRadius * linkScale;
+                const aromaticOffset = linkRadius + aromaticScale * linkRadius + aromaticScale * linkRadius * aromaticSpacing;
+
+                v3setMagnitude(tmpV12, v3sub(tmpV12, vb, va), linkRadius * 0.5);
+                v3add(va, va, tmpV12);
+                v3sub(vb, vb, tmpV12);
+
+                cylinderProps.radiusTop = cylinderProps.radiusBottom = linkRadius * aromaticScale;
                 cylinderProps.topCap = cylinderProps.bottomCap = dashCap;
-                v3setMagnitude(vShift, vShift, absOffset * aromaticOffsetFactor);
+                v3setMagnitude(vShift, vShift, aromaticOffset);
                 v3sub(va, va, vShift);
                 v3sub(vb, vb, vShift);
-                addFixedCountDashedCylinder(builderState, va, vb, 0.5, 3, cylinderProps);
+                addFixedCountDashedCylinder(builderState, va, vb, 0.5, aromaticSegmentCount, cylinderProps);
 
                 if (linkStyle === LinkStyle.MirroredAromatic) {
-                    v3setMagnitude(vShift, vShift, absOffset * aromaticOffsetFactor * 2);
+                    v3setMagnitude(vShift, vShift, aromaticOffset * 2);
+                    v3add(va, va, vShift);
+                    v3add(vb, vb, vShift);
+                    addFixedCountDashedCylinder(builderState, va, vb, 0.5, aromaticSegmentCount, cylinderProps);
+                }
+            } else if (linkStyle === LinkStyle.OffsetDouble || linkStyle === LinkStyle.OffsetTriple) {
+                const multipleOffset = linkRadius + multiRadius + linkScale * linkRadius * linkSpacing;
+                v3setMagnitude(vShift, vShift, multipleOffset);
+
+                cylinderProps.radiusTop = cylinderProps.radiusBottom = linkRadius;
+                addCylinder(builderState, va, vb, 0.5, cylinderProps);
+
+                v3scale(tmpV12, tmpV12, linkSpacing * linkScale * 0.2);
+                v3add(va, va, tmpV12);
+                v3sub(vb, vb, tmpV12);
+
+                cylinderProps.radiusTop = cylinderProps.radiusBottom = multiRadius;
+                cylinderProps.topCap = dirFlag ? linkStub : dashCap;
+                cylinderProps.bottomCap = dirFlag ? dashCap : linkStub;
+                v3setMagnitude(vShift, vShift, multipleOffset);
+                v3sub(va, va, vShift);
+                v3sub(vb, vb, vShift);
+                addCylinder(builderState, va, vb, 0.5, cylinderProps);
+
+                if (order === 3) {
+                    v3setMagnitude(vShift, vShift, multipleOffset * 2);
                     v3add(va, va, vShift);
                     v3add(vb, vb, vShift);
-                    addFixedCountDashedCylinder(builderState, va, vb, 0.5, 3, cylinderProps);
+                    addCylinder(builderState, va, vb, 0.5, cylinderProps);
                 }
             } else {
                 v3setMagnitude(vShift, vShift, absOffset);
@@ -208,7 +246,7 @@ export function createLinkCylinderImpostors(ctx: VisualContext, linkBuilder: Lin
 
     if (!linkCount) return Cylinders.createEmpty(cylinders);
 
-    const { linkScale, linkSpacing, linkCap, dashCount, dashScale, dashCap, stubCap } = props;
+    const { linkScale, linkSpacing, linkCap, aromaticScale, aromaticSpacing, aromaticDashCount, dashCount, dashScale, dashCap, stubCap } = props;
 
     const cylindersCountEstimate = linkCount * 2;
     const builder = CylindersBuilder.create(cylindersCountEstimate, cylindersCountEstimate / 4, cylinders);
@@ -222,9 +260,8 @@ export function createLinkCylinderImpostors(ctx: VisualContext, linkBuilder: Lin
     const segmentCount = dashCount % 2 === 1 ? dashCount : dashCount + 1;
     const lengthScale = 0.5 - (0.5 / 2 / segmentCount);
 
-    const aromaticSegmentCount = 3;
+    const aromaticSegmentCount = aromaticDashCount + 1;
     const aromaticLengthScale = 0.5 - (0.5 / 2 / aromaticSegmentCount);
-    const aromaticOffsetFactor = 4.5;
 
     for (let edgeIndex = 0, _eI = linkCount; edgeIndex < _eI; ++edgeIndex) {
         if (ignore && ignore(edgeIndex)) continue;
@@ -242,9 +279,9 @@ export function createLinkCylinderImpostors(ctx: VisualContext, linkBuilder: Lin
             v3scale(tmpV12, v3sub(tmpV12, vb, va), lengthScale);
             v3sub(vb, vb, tmpV12);
             builder.addFixedCountDashes(va, vb, segmentCount, dashScale, dashCap, dashCap, edgeIndex);
-        } else if (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
-            const order = linkStyle === LinkStyle.Double ? 2 :
-                linkStyle === LinkStyle.Triple ? 3 : 1.5;
+        } else if (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.OffsetDouble || linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.OffsetTriple || linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
+            const order = (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.OffsetDouble) ? 2 :
+                (linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.OffsetTriple) ? 3 : 1.5;
             const multiScale = linkScale / (0.5 * order);
             const absOffset = (linkRadius - multiScale * linkRadius) * linkSpacing;
 
@@ -254,24 +291,40 @@ export function createLinkCylinderImpostors(ctx: VisualContext, linkBuilder: Lin
             if (linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
                 builder.add(va[0], va[1], va[2], vm[0], vm[1], vm[2], 1, linkCap, linkStub, edgeIndex);
 
+                const aromaticOffset = linkRadius + aromaticScale * linkRadius + aromaticScale * linkRadius * aromaticSpacing;
+
                 v3scale(tmpV12, v3sub(tmpV12, vb, va), aromaticLengthScale);
                 v3sub(vb, vb, tmpV12);
 
-                v3setMagnitude(vShift, vShift, absOffset * aromaticOffsetFactor);
+                v3setMagnitude(tmpV12, v3sub(tmpV12, vb, va), linkRadius * 0.5);
+                v3add(va, va, tmpV12);
+
+                v3setMagnitude(vShift, vShift, aromaticOffset);
                 v3sub(va, va, vShift);
                 v3sub(vb, vb, vShift);
-                builder.addFixedCountDashes(va, vb, aromaticSegmentCount, linkScale, dashCap, dashCap, edgeIndex);
+                builder.addFixedCountDashes(va, vb, aromaticSegmentCount, aromaticScale, dashCap, dashCap, edgeIndex);
 
                 if (linkStyle === LinkStyle.MirroredAromatic) {
-                    v3setMagnitude(vShift, vShift, absOffset * aromaticOffsetFactor * 2);
+                    v3setMagnitude(vShift, vShift, aromaticOffset * 2);
                     v3add(va, va, vShift);
                     v3add(vb, vb, vShift);
-                    builder.addFixedCountDashes(va, vb, aromaticSegmentCount, linkScale, dashCap, dashCap, edgeIndex);
+                    builder.addFixedCountDashes(va, vb, aromaticSegmentCount, aromaticScale, dashCap, dashCap, edgeIndex);
                 }
+            } else if (linkStyle === LinkStyle.OffsetDouble || linkStyle === LinkStyle.OffsetTriple) {
+                const multipleOffset = linkRadius + multiScale * linkRadius + linkScale * linkRadius * linkSpacing;
+                v3setMagnitude(vShift, vShift, multipleOffset);
+
+                builder.add(va[0], va[1], va[2], vm[0], vm[1], vm[2], 1, linkCap, linkStub, edgeIndex);
+
+                v3setMagnitude(tmpV12, v3sub(tmpV12, va, vb), linkRadius / 1.5);
+                v3sub(va, va, tmpV12);
+
+                if (order === 3) builder.add(va[0] + vShift[0], va[1] + vShift[1], va[2] + vShift[2], vm[0] + vShift[0], vm[1] + vShift[1], vm[2] + vShift[2], multiScale, linkCap, linkStub, edgeIndex);
+                builder.add(va[0] - vShift[0], va[1] - vShift[1], va[2] - vShift[2], vm[0] - vShift[0], vm[1] - vShift[1], vm[2] - vShift[2], multiScale, dashCap, linkStub, edgeIndex);
             } else {
                 v3setMagnitude(vShift, vShift, absOffset);
 
-                if (order === 3) builder.add(va[0], va[1], va[2], vm[0], vm[1], vm[2], multiScale, linkCap, false, edgeIndex);
+                if (order === 3) builder.add(va[0], va[1], va[2], vm[0], vm[1], vm[2], multiScale, linkCap, linkStub, edgeIndex);
                 builder.add(va[0] + vShift[0], va[1] + vShift[1], va[2] + vShift[2], vm[0] + vShift[0], vm[1] + vShift[1], vm[2] + vShift[2], multiScale, linkCap, linkStub, edgeIndex);
                 builder.add(va[0] - vShift[0], va[1] - vShift[1], va[2] - vShift[2], vm[0] - vShift[0], vm[1] - vShift[1], vm[2] - vShift[2], multiScale, linkCap, linkStub, edgeIndex);
             }
@@ -296,7 +349,7 @@ export function createLinkLines(ctx: VisualContext, linkBuilder: LinkBuilderProp
 
     if (!linkCount) return Lines.createEmpty(lines);
 
-    const { linkScale, linkSpacing, dashCount } = props;
+    const { linkScale, linkSpacing, aromaticDashCount, dashCount } = props;
 
     const linesCountEstimate = linkCount * 2;
     const builder = LinesBuilder.create(linesCountEstimate, linesCountEstimate / 4, lines);
@@ -310,9 +363,10 @@ export function createLinkLines(ctx: VisualContext, linkBuilder: LinkBuilderProp
     const segmentCount = dashCount % 2 === 1 ? dashCount : dashCount + 1;
     const lengthScale = 0.5 - (0.5 / 2 / segmentCount);
 
-    const aromaticSegmentCount = 3;
+    const aromaticSegmentCount = aromaticDashCount + 1;
     const aromaticLengthScale = 0.5 - (0.5 / 2 / aromaticSegmentCount);
     const aromaticOffsetFactor = 4.5;
+    const multipleOffsetFactor = 3;
 
     for (let edgeIndex = 0, _eI = linkCount; edgeIndex < _eI; ++edgeIndex) {
         if (ignore && ignore(edgeIndex)) continue;
@@ -328,9 +382,9 @@ export function createLinkLines(ctx: VisualContext, linkBuilder: LinkBuilderProp
             v3scale(tmpV12, v3sub(tmpV12, vb, va), lengthScale);
             v3sub(vb, vb, tmpV12);
             builder.addFixedCountDashes(va, vb, segmentCount, edgeIndex);
-        } else if (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
-            const order = linkStyle === LinkStyle.Double ? 2 :
-                linkStyle === LinkStyle.Triple ? 3 : 1.5;
+        } else if (linkStyle === LinkStyle.Double || linkStyle === LinkStyle.OffsetDouble || linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.OffsetTriple || linkStyle === LinkStyle.Aromatic || linkStyle === LinkStyle.MirroredAromatic) {
+            const order = linkStyle === LinkStyle.Double || linkStyle === LinkStyle.OffsetDouble ? 2 :
+                linkStyle === LinkStyle.Triple || linkStyle === LinkStyle.OffsetTriple ? 3 : 1.5;
             const multiRadius = 1 * (linkScale / (0.5 * order));
             const absOffset = (1 - multiRadius) * linkSpacing;
 
@@ -354,8 +408,18 @@ export function createLinkLines(ctx: VisualContext, linkBuilder: LinkBuilderProp
                     v3add(vb, vb, vShift);
                     builder.addFixedCountDashes(va, vb, aromaticSegmentCount, edgeIndex);
                 }
+            } else if (linkStyle === LinkStyle.OffsetDouble || linkStyle === LinkStyle.OffsetTriple) {
+                v3setMagnitude(vShift, vShift, absOffset * multipleOffsetFactor);
+
+                builder.add(va[0], va[1], va[2], vm[0], vm[1], vm[2], edgeIndex);
+
+                v3scale(tmpV12, v3sub(tmpV12, va, vb), linkSpacing * linkScale);
+                v3sub(va, va, tmpV12);
+
+                if (order === 3) builder.add(va[0] + vShift[0], va[1] + vShift[1], va[2] + vShift[2], vm[0] + vShift[0], vm[1] + vShift[1], vm[2] + vShift[2], edgeIndex);
+                builder.add(va[0] - vShift[0], va[1] - vShift[1], va[2] - vShift[2], vm[0] - vShift[0], vm[1] - vShift[1], vm[2] - vShift[2], edgeIndex);
             } else {
-                v3setMagnitude(vShift, vShift, absOffset);
+                v3setMagnitude(vShift, vShift, absOffset * 1.5);
 
                 if (order === 3) builder.add(va[0], va[1], va[2], vm[0], vm[1], vm[2], edgeIndex);
                 builder.add(va[0] + vShift[0], va[1] + vShift[1], va[2] + vShift[2], vm[0] + vShift[0], vm[1] + vShift[1], vm[2] + vShift[2], edgeIndex);

+ 8 - 0
src/mol-theme/label.ts

@@ -92,6 +92,14 @@ export function structureElementStatsLabel(stats: StructureElement.Stats, option
     return o.htmlStyling ? label : stripTags(label);
 }
 
+export function structureElementLociLabelMany(locis: StructureElement.Loci[], options: Partial<LabelOptions> = {}): string {
+    const stats = StructureElement.Stats.create();
+    for (const l of locis) {
+        StructureElement.Stats.add(stats, stats, StructureElement.Stats.ofLoci(l));
+    }
+    return structureElementStatsLabel(stats, options);
+}
+
 function _structureElementStatsLabel(stats: StructureElement.Stats, countsOnly = false, hidePrefix = false, condensed = false, reverse = false): string {
     const { structureCount, chainCount, residueCount, conformationCount, elementCount } = stats;
 

+ 10 - 2
src/mol-util/marker-action.ts

@@ -68,11 +68,19 @@ export function applyMarkerAction(array: Uint8Array, set: OrderedSet, action: Ma
     if (Interval.is(set)) {
         const start = Interval.start(set);
         const end = Interval.end(set);
-        const view = new Uint32Array(array.buffer, 0, array.buffer.byteLength >> 2);
-
         const viewStart = (start + 3) >> 2;
         const viewEnd = viewStart + ((end - 4 * viewStart) >> 2);
 
+        if (viewEnd <= viewStart) {
+            // avoid edge cases with overlapping front/end intervals
+            for (let i = start; i < end; ++i) {
+                applyMarkerActionAtPosition(array, i, action);
+            }
+            return true;
+        }
+
+        const view = new Uint32Array(array.buffer, 0, array.buffer.byteLength >> 2);
+
         const frontStart = start;
         const frontEnd = Math.min(4 * viewStart, end);
         const backStart = Math.max(start, 4 * viewEnd);

+ 1 - 1
src/mol-util/zip/bin.ts

@@ -92,7 +92,7 @@ export function sizeUTF8(str: string) {
     for (let ci = 0; ci < strl; ci++) {
         const code = str.charCodeAt(ci);
         if ((code & (0xffffffff - (1 << 7) + 1)) === 0) {
-            i++ ;
+            i++;
         } else if ((code & (0xffffffff - (1 << 11) + 1)) === 0) {
             i += 2;
         } else if ((code & (0xffffffff - (1 << 16) + 1)) === 0) {

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

@@ -1,3 +1,6 @@
+# 0.9.8
+* fix support for chem_comp_bond and struct_conn categories
+
 # 0.9.7
 * add Surrounding Ligands query
 

+ 5 - 5
src/servers/model/config.ts

@@ -229,10 +229,10 @@ function addJsonConfigArgs(parser: argparse.ArgumentParser) {
             'JSON config file path',
             'If a property is not specified, cmd line param/OS variable/default value are used.'
         ].join('\n'),
-        required: false
+        required: false,
     });
-    parser.add_argument('--printCfg', { help: 'Print current config for validation and exit.', required: false, nargs: 0 });
-    parser.add_argument('--cfgTemplate', { help: 'Prints default JSON config template to be modified and exits.', required: false, nargs: 0 });
+    parser.add_argument('--printCfg', { help: 'Print current config for validation and exit.', required: false, action: 'store_true' });
+    parser.add_argument('--cfgTemplate', { help: 'Prints default JSON config template to be modified and exits.', required: false, action: 'store_true' });
 }
 
 function setConfig(config: ModelServerConfig) {
@@ -271,7 +271,7 @@ function parseConfigArguments() {
 export function configureServer() {
     const config = parseConfigArguments();
 
-    if (config.cfgTemplate !== null) {
+    if (!!config.cfgTemplate) {
         console.log(JSON.stringify(ModelServerConfigTemplate, null, 2));
         process.exit(0);
     }
@@ -284,7 +284,7 @@ export function configureServer() {
             setConfig(cfg);
         }
 
-        if (config.printCfg !== null) {
+        if (!!config.printCfg) {
             console.log(JSON.stringify(ModelServerConfig, null, 2));
             process.exit(0);
         }

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

@@ -1,7 +1,7 @@
 /**
- * Copyright (c) 2018-2019 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 David Sehnal <david.sehnal@gmail.com>
  */
 
-export const VERSION = '0.9.7';
+export const VERSION = '0.9.8';

+ 6 - 6
src/servers/volume/config.ts

@@ -89,10 +89,10 @@ function addJsonConfigArgs(parser: argparse.ArgumentParser) {
             'JSON config file path',
             'If a property is not specified, cmd line param/OS variable/default value are used.'
         ].join('\n'),
-        required: false
+        required: false,
     });
-    parser.add_argument('--printCfg', { help: 'Print current config for validation and exit.', required: false, nargs: 0 });
-    parser.add_argument('--cfgTemplate', { help: 'Prints default JSON config template to be modified and exits.', required: false, nargs: 0 });
+    parser.add_argument('--printCfg', { help: 'Print current config for validation and exit.', required: false, action: 'store_true' });
+    parser.add_argument('--cfgTemplate', { help: 'Prints default JSON config template to be modified and exits.', required: false, action: 'store_true' });
 }
 
 export interface ServerJsonConfig {
@@ -165,7 +165,7 @@ export function configureServer() {
     addLimitsArgs(parser);
     const config = parser.parse_args() as FullServerConfig & ServerJsonConfig;
 
-    if (config.cfgTemplate !== null) {
+    if (!!config.cfgTemplate) {
         console.log(JSON.stringify(ServerConfigTemplate, null, 2));
         process.exit(0);
     }
@@ -178,7 +178,7 @@ export function configureServer() {
             setConfig(cfg);
         }
 
-        if (config.printCfg !== null) {
+        if (config.printCfg) {
             console.log(JSON.stringify({ ...ServerConfig, ...LimitsConfig }, null, 2));
             process.exit(0);
         }
@@ -197,7 +197,7 @@ export function configureLocal() {
         description: VOLUME_SERVER_HEADER
     });
     parser.add_argument('--jobs', { help: `Path to a JSON file with job specification.`, required: false });
-    parser.add_argument('--jobsTemplate', { help: 'Print example template for jobs.json and exit.', required: false, nargs: 0 });;
+    parser.add_argument('--jobsTemplate', { help: 'Print example template for jobs.json and exit.', required: false, nargs: 0 });
     addJsonConfigArgs(parser);
     addLimitsArgs(parser);
 

Some files were not shown because too many files changed in this diff