فهرست منبع

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

Alexander Rose 3 سال پیش
والد
کامیت
94fd5a97d6
82فایلهای تغییر یافته به همراه1130 افزوده شده و 14456 حذف شده
  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.

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 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);
 

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است