Sebastian Bittrich 1 سال پیش
والد
کامیت
a678893bdb
100فایلهای تغییر یافته به همراه2619 افزوده شده و 752 حذف شده
  1. 52 0
      CHANGELOG.md
  2. 274 264
      package-lock.json
  3. 32 31
      package.json
  4. 2 2
      scripts/deploy.js
  5. 4 2
      src/apps/docking-viewer/viewport.tsx
  6. 9 2
      src/examples/image-renderer/index.ts
  7. 45 7
      src/examples/lighting/index.ts
  8. 3 1
      src/extensions/cellpack/model.ts
  9. 11 2
      src/extensions/dnatco/ntc-tube/color.ts
  10. 34 11
      src/extensions/dnatco/ntc-tube/util.ts
  11. 1 1
      src/extensions/volumes-and-segmentations/index.ts
  12. 1 2
      src/extensions/zenodo/ui.tsx
  13. 37 0
      src/mol-canvas3d/_spec/camera.spec.ts
  14. 12 8
      src/mol-canvas3d/camera.ts
  15. 2 2
      src/mol-canvas3d/camera/util.ts
  16. 18 8
      src/mol-canvas3d/canvas3d.ts
  17. 424 40
      src/mol-canvas3d/controls/trackball.ts
  18. 9 4
      src/mol-canvas3d/helper/interaction-events.ts
  19. 1 1
      src/mol-canvas3d/passes/marking.ts
  20. 1 1
      src/mol-canvas3d/passes/pick.ts
  21. 229 75
      src/mol-canvas3d/passes/postprocessing.ts
  22. 19 19
      src/mol-gl/_spec/gl.shim.ts
  23. 7 4
      src/mol-gl/renderable/schema.ts
  24. 15 7
      src/mol-gl/renderer.ts
  25. 1 3
      src/mol-gl/scene.ts
  26. 10 1
      src/mol-gl/shader/chunks/apply-light-color.glsl.ts
  27. 1 0
      src/mol-gl/shader/chunks/common-frag-params.glsl.ts
  28. 1 0
      src/mol-gl/shader/direct-volume.frag.ts
  29. 13 4
      src/mol-gl/shader/outlines.frag.ts
  30. 5 18
      src/mol-gl/shader/postprocessing.frag.ts
  31. 14 5
      src/mol-gl/shader/ssao-blur.frag.ts
  32. 85 26
      src/mol-gl/shader/ssao.frag.ts
  33. 38 0
      src/mol-gl/webgl/compat.ts
  34. 3 1
      src/mol-gl/webgl/program.ts
  35. 10 13
      src/mol-gl/webgl/render-item.ts
  36. 2 2
      src/mol-gl/webgl/resources.ts
  37. 1 1
      src/mol-gl/webgl/vertex-array.ts
  38. 1 1
      src/mol-io/common/file-handle.ts
  39. 2 3
      src/mol-io/reader/common/text/tokenizer.ts
  40. 74 0
      src/mol-math/geometry/_spec/frustum3d.spec.ts
  41. 40 0
      src/mol-math/geometry/_spec/plane3d.spec.ts
  42. 21 0
      src/mol-math/geometry/_spec/polygon.spec.ts
  43. 24 0
      src/mol-math/geometry/polygon.ts
  44. 41 9
      src/mol-math/geometry/primitives/box3d.ts
  45. 99 0
      src/mol-math/geometry/primitives/frustum3d.ts
  46. 93 0
      src/mol-math/geometry/primitives/plane3d.ts
  47. 2 1
      src/mol-math/geometry/primitives/sphere3d.ts
  48. 9 1
      src/mol-math/linear-algebra/3d/mat3.ts
  49. 1 1
      src/mol-math/linear-algebra/3d/mat4.ts
  50. 1 1
      src/mol-math/linear-algebra/3d/quat.ts
  51. 1 1
      src/mol-math/linear-algebra/3d/vec2.ts
  52. 23 5
      src/mol-math/linear-algebra/3d/vec3.ts
  53. 1 1
      src/mol-math/linear-algebra/3d/vec4.ts
  54. 1 1
      src/mol-model-props/computed/interactions/contacts.ts
  55. 5 5
      src/mol-model/structure/coordinates/coordinates.ts
  56. 6 2
      src/mol-model/structure/model/model.ts
  57. 5 1
      src/mol-model/structure/structure/element/location.ts
  58. 1 1
      src/mol-model/structure/structure/element/loci.ts
  59. 4 2
      src/mol-model/structure/structure/structure.ts
  60. 27 13
      src/mol-model/structure/structure/unit/bonds/inter-compute.ts
  61. 4 4
      src/mol-plugin-state/actions/file.ts
  62. 3 3
      src/mol-plugin-state/actions/structure.ts
  63. 2 2
      src/mol-plugin-state/actions/volume.ts
  64. 2 2
      src/mol-plugin-state/builder/data.ts
  65. 3 3
      src/mol-plugin-state/builder/structure/hierarchy-preset.ts
  66. 38 0
      src/mol-plugin-state/builder/structure/representation-preset.ts
  67. 3 3
      src/mol-plugin-state/formats/provider.ts
  68. 2 2
      src/mol-plugin-state/formats/registry.ts
  69. 30 6
      src/mol-plugin-state/manager/camera.ts
  70. 218 0
      src/mol-plugin-state/manager/focus-camera/orient-axes.ts
  71. 2 1
      src/mol-plugin-state/manager/loci-label.ts
  72. 7 1
      src/mol-plugin-state/manager/structure/component.ts
  73. 1 1
      src/mol-plugin-state/transforms/data.ts
  74. 29 9
      src/mol-plugin-state/transforms/model.ts
  75. 6 6
      src/mol-plugin-state/transforms/representation.ts
  76. 2 7
      src/mol-plugin-ui/controls.tsx
  77. 2 2
      src/mol-plugin-ui/controls/slider.tsx
  78. 3 2
      src/mol-plugin-ui/left-panel.tsx
  79. 31 4
      src/mol-plugin-ui/skin/base/components/viewport.scss
  80. 1 1
      src/mol-plugin-ui/state/snapshots.tsx
  81. 6 1
      src/mol-plugin-ui/structure/measurements.tsx
  82. 31 5
      src/mol-plugin-ui/structure/quick-styles.tsx
  83. 3 1
      src/mol-plugin-ui/structure/selection.tsx
  84. 8 2
      src/mol-plugin-ui/structure/superposition.tsx
  85. 46 15
      src/mol-plugin-ui/viewport.tsx
  86. 1 1
      src/mol-plugin-ui/viewport/help.tsx
  87. 2 1
      src/mol-plugin-ui/viewport/simple-settings.tsx
  88. 107 8
      src/mol-plugin/behavior/dynamic/camera.ts
  89. 6 5
      src/mol-plugin/behavior/dynamic/representation.ts
  90. 17 2
      src/mol-plugin/behavior/static/camera.ts
  91. 6 3
      src/mol-plugin/commands.ts
  92. 7 5
      src/mol-plugin/context.ts
  93. 28 6
      src/mol-plugin/headless-plugin-context.ts
  94. 2 1
      src/mol-plugin/spec.ts
  95. 1 1
      src/mol-plugin/state.ts
  96. 18 12
      src/mol-plugin/util/headless-screenshot.ts
  97. 2 2
      src/mol-plugin/util/viewport-screenshot.ts
  98. 14 1
      src/mol-repr/representation.ts
  99. 9 2
      src/mol-repr/shape/representation.ts
  100. 8 2
      src/mol-repr/structure/complex-representation.ts

+ 52 - 0
CHANGELOG.md

@@ -6,6 +6,58 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- Add a uniform color theme for NtC tube that still paints residue and segment dividers in a different color
+- Fix bond assignments `struct_conn` records referencing waters
+- Fix `PluginState.setSnapshot` triggering unnecessary state updates
+
+## [v3.34.0] - 2023-04-16
+
+- Avoid `renderMarkingDepth` for fully transparent renderables
+- Remove `camera.far` doubling workaround
+- Add `ModifiersKeys.areNone` helper function
+- Do not render NtC tube segments unless all required atoms are present in the structure
+- Fix rendering issues caused by VAO reuse
+- Add "Zoom All", "Orient Axes", "Reset Axes" buttons to the "Reset Camera" button
+- Improve trackball move-state handling when key bindings use modifiers
+- Fix rendering with very small viewport and SSAO enabled
+- Fix `.getAllLoci` for structure representations with `structure.child`
+- Fix `readAllLinesAsync` refering to dom length property
+- Make mol-util/file-info node compatible
+- Add `eachLocation` to representation/visual interface
+
+## [v3.33.0] - 2023-04-02
+
+- Handle resizes of viewer element even when window remains the same size
+- Throttle canvas resize events
+- Selection toggle buttons hidden if selection mode is off
+- Camera focus loci bindings allow reset on click-away to be overridden
+- Input/controls improvements
+    - Move or fly around the scene using keys
+    - Pointer lock to look around scene
+    - Toggle spin/rock animation using keys
+- Apply bumpiness as lightness variation with `ignoreLight`
+- Remove `JSX` reference from `loci-labels.ts`
+- Fix overpaint/transparency/substance smoothing not updated when geometry changes
+- Fix camera project/unproject when using offset viewport
+- Add support for loading all blocks from a mmcif file as a trajectory
+- Add `Frustum3D` and `Plane3D` math primitives
+- Include `occupancy` and `B_iso_or_equiv` when creating `Conformation` from `Model`
+- Remove LazyImports (introduced in v3.31.1)
+
+## [v3.32.0] - 2023-03-20
+
+- Avoid rendering of fully transparent renderables
+- Add occlusion color parameter
+- Fix issue with outlines and orthographic camera
+- Reduce over-blurring occlusion at larger view distances
+- Fix occlusion artefact with non-canvas viewport and pixel-ratio > 1
+- Update nodejs-shims conditionals to handle polyfilled document object in NodeJS environment.
+- Ensure marking edges are at least one pixel wide
+- Add exposure parameter to renderer
+- Only trigger marking when mouse is directly over canvas
+- Fix blurry occlusion in screenshots
+- [Breaking] Add `setFSModule` to `mol-util/data-source` instead of trying to trick WebPack
+
 ## [v3.31.4] - 2023-02-24
 
 - Allow link cylinder/line `dashCount` set to '0'

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 274 - 264
package-lock.json


+ 32 - 31
package.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "3.31.4",
+  "version": "3.34.0",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -96,51 +96,52 @@
     "Ke Ma <mark.ma@rcsb.org>",
     "Jason Pattle <jpattle@exscientia.co.uk>",
     "David Williams <dwilliams@nobiastx.com>",
-    "Zhenyu Zhang <jump2cn@gmail.com>"
+    "Zhenyu Zhang <jump2cn@gmail.com>",
+    "Russell Parker <russell@benchling.com>"
   ],
   "license": "MIT",
   "devDependencies": {
-    "@graphql-codegen/add": "^4.0.0",
-    "@graphql-codegen/cli": "^3.0.0",
+    "@graphql-codegen/add": "^4.0.1",
+    "@graphql-codegen/cli": "^3.3.0",
     "@graphql-codegen/time": "^4.0.0",
-    "@graphql-codegen/typescript": "^3.0.0",
+    "@graphql-codegen/typescript": "^3.0.3",
     "@graphql-codegen/typescript-graphql-files-modules": "^2.2.1",
-    "@graphql-codegen/typescript-graphql-request": "^4.5.8",
-    "@graphql-codegen/typescript-operations": "^3.0.0",
+    "@graphql-codegen/typescript-graphql-request": "^4.5.9",
+    "@graphql-codegen/typescript-operations": "^3.0.3",
     "@types/cors": "^2.8.13",
     "@types/gl": "^6.0.2",
     "@types/jpeg-js": "^0.3.7",
     "@types/pngjs": "^6.0.1",
-    "@types/jest": "^29.4.0",
-    "@types/react": "^18.0.27",
-    "@types/react-dom": "^18.0.10",
-    "@typescript-eslint/eslint-plugin": "^5.50.0",
-    "@typescript-eslint/parser": "^5.50.0",
+    "@types/jest": "^29.5.0",
+    "@types/react": "^18.0.35",
+    "@types/react-dom": "^18.0.11",
+    "@typescript-eslint/eslint-plugin": "^5.58.0",
+    "@typescript-eslint/parser": "^5.58.0",
     "benchmark": "^2.1.4",
-    "concurrently": "^7.6.0",
-    "cpx2": "^4.2.0",
+    "concurrently": "^8.0.1",
+    "cpx2": "^4.2.3",
     "crypto-browserify": "^3.12.0",
     "css-loader": "^6.7.3",
-    "eslint": "^8.33.0",
+    "eslint": "^8.38.0",
     "extra-watch-webpack-plugin": "^1.0.3",
     "file-loader": "^6.2.0",
-    "fs-extra": "^11.1.0",
+    "fs-extra": "^11.1.1",
     "graphql": "^16.6.0",
     "http-server": "^14.1.1",
-    "jest": "^29.4.1",
-    "mini-css-extract-plugin": "^2.7.2",
+    "jest": "^29.5.0",
+    "mini-css-extract-plugin": "^2.7.5",
     "path-browserify": "^1.0.1",
     "raw-loader": "^4.0.2",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "sass": "^1.58.0",
-    "sass-loader": "^13.2.0",
-    "simple-git": "^3.16.0",
+    "sass": "^1.62.0",
+    "sass-loader": "^13.2.2",
+    "simple-git": "^3.17.0",
     "stream-browserify": "^3.0.0",
-    "style-loader": "^3.3.1",
-    "ts-jest": "^29.0.5",
-    "typescript": "^4.9.5",
-    "webpack": "^5.75.0",
+    "style-loader": "^3.3.2",
+    "ts-jest": "^29.1.0",
+    "typescript": "^5.0.4",
+    "webpack": "^5.79.0",
     "webpack-cli": "^5.0.1"
   },
   "dependencies": {
@@ -148,20 +149,20 @@
     "@types/benchmark": "^2.1.2",
     "@types/compression": "1.7.2",
     "@types/express": "^4.17.17",
-    "@types/node": "^16.18.12",
-    "@types/node-fetch": "^2.6.2",
+    "@types/node": "^16.18.23",
+    "@types/node-fetch": "^2.6.3",
     "@types/swagger-ui-dist": "3.30.1",
     "argparse": "^2.0.1",
-    "body-parser": "^1.20.1",
+    "body-parser": "^1.20.2",
     "compression": "^1.7.4",
     "cors": "^2.8.5",
     "express": "^4.18.2",
     "h264-mp4-encoder": "^1.0.12",
-    "immer": "^9.0.19",
-    "immutable": "^4.2.3",
+    "immer": "^9.0.21",
+    "immutable": "^4.3.0",
     "node-fetch": "^2.6.9",
     "rxjs": "^7.8.0",
-    "swagger-ui-dist": "^4.15.5",
+    "swagger-ui-dist": "^4.18.2",
     "tslib": "^2.5.0",
     "util.promisify": "^1.1.1",
     "xhr2": "^0.2.1"

+ 2 - 2
scripts/deploy.js

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -15,7 +15,7 @@ const deployDir = path.resolve(buildDir, 'deploy/');
 const localPath = path.resolve(deployDir, 'molstar.github.io/');
 
 const analyticsTag = /<!-- __MOLSTAR_ANALYTICS__ -->/g;
-const analyticsCode = `<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c414cbae2d284ea995171a81e4a3e721"}'></script><!-- End Cloudflare Web Analytics -->`;
+const analyticsCode = `<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c414cbae2d284ea995171a81e4a3e721"}'></script><!-- End Cloudflare Web Analytics --><script defer src="https://web3dsurvey.com/collector.js"></script>`;
 
 function log(command, stdout, stderr) {
     if (command) {

+ 4 - 2
src/apps/docking-viewer/viewport.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -45,11 +45,13 @@ function occlusionStyle(plugin: PluginContext) {
         postprocessing: {
             ...plugin.canvas3d!.props.postprocessing,
             occlusion: { name: 'on', params: {
-                bias: 0.8,
                 blurKernelSize: 15,
+                multiScale: { name: 'off', params: {} },
                 radius: 5,
+                bias: 0.8,
                 samples: 32,
                 resolutionScale: 1,
+                color: Color(0x000000),
             } },
             outline: { name: 'on', params: {
                 scale: 1.0,

+ 9 - 2
src/examples/image-renderer/index.ts

@@ -12,15 +12,21 @@
 import { ArgumentParser } from 'argparse';
 import fs from 'fs';
 import path from 'path';
+import gl from 'gl';
+import pngjs from 'pngjs';
+import jpegjs from 'jpeg-js';
 
 import { Download, ParseCif } from '../../mol-plugin-state/transforms/data';
 import { ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif } from '../../mol-plugin-state/transforms/model';
 import { StructureRepresentation3D } from '../../mol-plugin-state/transforms/representation';
 import { HeadlessPluginContext } from '../../mol-plugin/headless-plugin-context';
 import { DefaultPluginSpec } from '../../mol-plugin/spec';
-import { STYLIZED_POSTPROCESSING } from '../../mol-plugin/util/headless-screenshot';
+import { ExternalModules, STYLIZED_POSTPROCESSING } from '../../mol-plugin/util/headless-screenshot';
+import { setFSModule } from '../../mol-util/data-source';
 
 
+setFSModule(fs);
+
 interface Args {
     pdbId: string,
     outDirectory: string
@@ -42,7 +48,8 @@ async function main() {
     console.log('Outputs:', args.outDirectory);
 
     // Create a headless plugin
-    const plugin = new HeadlessPluginContext(DefaultPluginSpec(), { width: 800, height: 800 });
+    const externalModules: ExternalModules = { gl, pngjs, 'jpeg-js': jpegjs };
+    const plugin = new HeadlessPluginContext(externalModules, DefaultPluginSpec(), { width: 800, height: 800 });
     await plugin.init();
 
     // Download and visualize data in the plugin

+ 45 - 7
src/examples/lighting/index.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -24,9 +24,31 @@ const Canvas3DPresets = {
     illustrative: {
         canvas3d: <Preset>{
             postprocessing: {
-                occlusion: { name: 'on', params: { samples: 32, radius: 6, bias: 1.4, blurKernelSize: 15, resolutionScale: 1 } },
-                outline: { name: 'on', params: { scale: 1, threshold: 0.33, color: Color(0x000000), includeTransparent: true, } },
-                shadow: { name: 'off', params: {} },
+                occlusion: {
+                    name: 'on',
+                    params: {
+                        samples: 32,
+                        multiScale: { name: 'off', params: {} },
+                        radius: 5,
+                        bias: 0.8,
+                        blurKernelSize: 15,
+                        resolutionScale: 1,
+                        color: Color(0x000000),
+                    }
+                },
+                outline: {
+                    name: 'on',
+                    params: {
+                        scale: 1,
+                        threshold: 0.33,
+                        color: Color(0x000000),
+                        includeTransparent: true,
+                    }
+                },
+                shadow: {
+                    name: 'off',
+                    params: {}
+                },
             },
             renderer: {
                 ambientIntensity: 1.0,
@@ -37,9 +59,25 @@ const Canvas3DPresets = {
     occlusion: {
         canvas3d: <Preset>{
             postprocessing: {
-                occlusion: { name: 'on', params: { samples: 32, radius: 6, bias: 1.4, blurKernelSize: 15, resolutionScale: 1 } },
-                outline: { name: 'off', params: {} },
-                shadow: { name: 'off', params: {} },
+                occlusion: {
+                    name: 'on',
+                    params: {
+                        samples: 32,
+                        multiScale: { name: 'off', params: {} },
+                        radius: 5,
+                        bias: 0.8,
+                        blurKernelSize: 15,
+                        resolutionScale: 1,
+                    }
+                },
+                outline: {
+                    name: 'off',
+                    params: {}
+                },
+                shadow: {
+                    name: 'off',
+                    params: {}
+                },
             },
             renderer: {
                 ambientIntensity: 0.4,

+ 3 - 1
src/extensions/cellpack/model.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author Ludovic Autin <ludovic.autin@gmail.com>
@@ -600,10 +600,12 @@ export const LoadCellPackModel = StateAction.build({
                     name: 'on',
                     params: {
                         samples: 32,
+                        multiScale: { name: 'off', params: {} },
                         radius: 8,
                         bias: 1,
                         blurKernelSize: 15,
                         resolutionScale: 1,
+                        color: Color(0x000000),
                     }
                 },
                 shadow: {

+ 11 - 2
src/extensions/dnatco/ntc-tube/color.ts

@@ -31,7 +31,8 @@ type NtCTubeColors = typeof NtCTubeColors;
 export const NtCTubeColorThemeParams = {
     colors: PD.MappedStatic('default', {
         'default': PD.EmptyGroup(),
-        'custom': PD.Group(getColorMapParams(NtCTubeColors))
+        'custom': PD.Group(getColorMapParams(NtCTubeColors)),
+        'uniform': PD.Color(Color(0xEEEEEE)),
     }),
     markResidueBoundaries: PD.Boolean(true),
     markSegmentBoundaries: PD.Boolean(true),
@@ -43,7 +44,15 @@ export function getNtCTubeColorThemeParams(ctx: ThemeDataContext) {
 }
 
 export function NtCTubeColorTheme(ctx: ThemeDataContext, props: PD.Values<NtCTubeColorThemeParams>): ColorTheme<NtCTubeColorThemeParams> {
-    const colorMap = props.colors.name === 'default' ? NtCTubeColors : props.colors.params;
+    const colorMap = props.colors.name === 'default'
+        ? NtCTubeColors
+        : props.colors.name === 'custom'
+            ? props.colors.params
+            : ColorMap({
+                ...Object.fromEntries(ObjectKeys(NtCTubeColors).map(item => [item, props.colors.params])),
+                residueMarker: NtCTubeColors.residueMarker,
+                stepBoundaryMarker: NtCTubeColors.stepBoundaryMarker
+            }) as NtCTubeColors;
 
     function color(location: Location, isSecondary: boolean): Color {
         if (NTT.isLocation(location)) {

+ 34 - 11
src/extensions/dnatco/ntc-tube/util.ts

@@ -14,11 +14,12 @@ import { ChainIndex, ElementIndex, ResidueIndex, Structure, StructureElement, Un
 
 function getAtomPosition(vec: Vec3, loc: StructureElement.Location, residue: DnatcoUtil.Residue, names: string[], altId: string, insCode: string) {
     const eI = DnatcoUtil.getAtomIndex(loc, residue, names, altId, insCode);
-    if (eI !== -1)
+    if (eI !== -1) {
         loc.unit.conformation.invariantPosition(eI, vec);
-    else {
-        vec[0] = 0; vec[1] = 0; vec[2] = 0;
+        return true;
     }
+
+    return false; // Atom not found
 }
 
 const p_1 = Vec3();
@@ -29,19 +30,38 @@ const p3 = Vec3();
 const p4 = Vec3();
 const pP = Vec3();
 
+const C5PrimeNames = ['C5\'', 'C5*'];
+const O3PrimeNames = ['O3\'', 'O3*'];
+const O5PrimeNames = ['O5\'', 'O5*'];
+const PNames = ['P'];
+
 function getPoints(
     loc: StructureElement.Location,
     r0: DnatcoUtil.Residue | undefined, r1: DnatcoUtil.Residue, r2: DnatcoUtil.Residue,
     altId0: string, altId1: string, altId2: string,
     insCode0: string, insCode1: string, insCode2: string,
 ) {
-    if (r0) getAtomPosition(p_1, loc, r0, ['C5\'', 'C5*'], altId0, insCode0);
-    r0 ? getAtomPosition(p0, loc, r0, ['O3\'', 'O3*'], altId0, insCode0) : getAtomPosition(p0, loc, r1, ['O5\'', 'O5*'], altId1, insCode1);
-    getAtomPosition(p1, loc, r1, ['C5\'', 'C5*'], altId1, insCode1);
-    getAtomPosition(p2, loc, r1, ['O3\'', 'O3*'], altId1, insCode1);
-    getAtomPosition(p3, loc, r2, ['C5\'', 'C5*'], altId2, insCode2);
-    getAtomPosition(p4, loc, r2, ['O3\'', 'O3*'], altId2, insCode2);
-    getAtomPosition(pP, loc, r2, ['P'], altId2, insCode2);
+    if (r0) {
+        if (!getAtomPosition(p_1, loc, r0, C5PrimeNames, altId0, insCode0))
+            return void 0;
+        if (!getAtomPosition(p0, loc, r0, O3PrimeNames, altId0, insCode0))
+            return void 0;
+    } else {
+        if (!getAtomPosition(p0, loc, r1, O5PrimeNames, altId1, insCode1))
+            return void 0;
+    }
+
+    if (!getAtomPosition(p1, loc, r1, C5PrimeNames, altId1, insCode1))
+        return void 0;
+    if (!getAtomPosition(p2, loc, r1, O3PrimeNames, altId1, insCode1))
+        return void 0;
+
+    if (!getAtomPosition(p3, loc, r2, C5PrimeNames, altId2, insCode2))
+        return void 0;
+    if (!getAtomPosition(p4, loc, r2, O3PrimeNames, altId2, insCode2))
+        return void 0;
+    if (!getAtomPosition(pP, loc, r2, PNames, altId2, insCode2))
+        return void 0;
 
     return { p_1, p0, p1, p2, p3, p4, pP };
 }
@@ -142,9 +162,12 @@ export class NtCTubeSegmentsIterator {
         const insCodeTwo = step.PDB_ins_code_2;
         const followsGap = !!r0 && hasGapElements(r0, this.loc.unit) && hasGapElements(r1, this.loc.unit);
         const precedesDiscontinuity = r3 ? r3.index !== r2.index + 1 : false;
+        const points = getPoints(this.loc, r0, r1, r2, altIdPrev, this.altIdOne, altIdTwo, insCodePrev, this.insCodeOne, insCodeTwo);
+        if (!points)
+            return void 0;
 
         return {
-            ...getPoints(this.loc, r0, r1, r2, altIdPrev, this.altIdOne, altIdTwo, insCodePrev, this.insCodeOne, insCodeTwo),
+            ...points,
             stepIdx,
             followsGap,
             firstInChain: !r0,

+ 1 - 1
src/extensions/volumes-and-segmentations/index.ts

@@ -19,7 +19,7 @@ import { VolsegEntryFromRoot, VolsegGlobalStateFromRoot, VolsegStateFromEntry }
 import { VolsegUI } from './ui';
 
 
-const DEBUGGING = window.location.hostname === 'localhost';
+const DEBUGGING = typeof window !== 'undefined' ? window?.location?.hostname === 'localhost' : false;
 
 export const VolsegVolumeServerConfig = {
     // DefaultServer: new PluginConfigItem('volseg-volume-server', DEFAULT_VOLUME_SERVER_V2),

+ 1 - 2
src/extensions/zenodo/ui.tsx

@@ -202,7 +202,7 @@ export class ZenodoImportUI extends CollapsableControls<{}, State> {
                 }));
             } else if (t.name === 'trajectory') {
                 const [topologyUrl, topologyFormat, topologyIsBinary] = t.params.topology.split('|');
-                const [coordinatesUrl, coordinatesFormat, coordinatesIsBinary] = t.params.coordinates.split('|');
+                const [coordinatesUrl, coordinatesFormat] = t.params.coordinates.split('|');
 
                 await this.plugin.runTask(this.plugin.state.data.applyAction(LoadTrajectory, {
                     source: {
@@ -216,7 +216,6 @@ export class ZenodoImportUI extends CollapsableControls<{}, State> {
                             coordinates: {
                                 url: coordinatesUrl,
                                 format: coordinatesFormat as any,
-                                isBinary: coordinatesIsBinary === 'true',
                             },
                         }
                     }

+ 37 - 0
src/mol-canvas3d/_spec/camera.spec.ts

@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Vec3, Vec4 } from '../../mol-math/linear-algebra';
+import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
+import { Viewport, cameraProject, cameraUnproject } from '../camera/util';
+
+describe('camera', () => {
+    it('project/unproject', () => {
+        const proj = Mat4.perspective(Mat4(), -1, 1, 1, -1, 1, 100);
+        const invProj = Mat4.invert(Mat4(), proj);
+
+        const c = Vec4();
+        const po = Vec3();
+
+        const vp = Viewport.create(0, 0, 100, 100);
+        const pi = Vec3.create(0, 0, 1);
+        cameraProject(c, pi, vp, proj);
+        expect(Vec4.equals(c, Vec4.create(50, 50, 2.020202, -1))).toBe(true);
+        cameraUnproject(po, c, vp, invProj);
+        expect(Vec3.equals(po, pi)).toBe(true);
+
+        Vec3.set(pi, 0.5, 0.5, 1);
+        cameraProject(c, pi, vp, proj);
+        cameraUnproject(po, c, vp, invProj);
+        expect(Vec3.equals(po, pi)).toBe(true);
+
+        Viewport.set(vp, 50, 50, 100, 100);
+        Vec3.set(pi, 0.5, 0.5, 1);
+        cameraProject(c, pi, vp, proj);
+        cameraUnproject(po, c, vp, invProj);
+        expect(Vec3.equals(po, pi)).toBe(true);
+    });
+});

+ 12 - 8
src/mol-canvas3d/camera.ts

@@ -194,7 +194,7 @@ class Camera implements ICamera {
     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
+        // clip position in addition to the one shifted by one pixel
         this.project(tmpClip, point);
         this.unproject(tmpPos1, tmpClip);
         tmpClip[0] += 1;
@@ -278,6 +278,7 @@ namespace Camera {
             fog: 50,
             clipFar: true,
             minNear: 5,
+            minFar: 0,
         };
     }
 
@@ -294,6 +295,7 @@ namespace Camera {
         fog: number
         clipFar: boolean
         minNear: number
+        minFar: number
     }
 
     export function copySnapshot(out: Snapshot, source?: Partial<Snapshot>) {
@@ -311,6 +313,7 @@ namespace Camera {
         if (typeof source.fog !== 'undefined') out.fog = source.fog;
         if (typeof source.clipFar !== 'undefined') out.clipFar = source.clipFar;
         if (typeof source.minNear !== 'undefined') out.minNear = source.minNear;
+        if (typeof source.minFar !== 'undefined') out.minFar = source.minFar;
 
         return out;
     }
@@ -323,6 +326,7 @@ namespace Camera {
             && a.fog === b.fog
             && a.clipFar === b.clipFar
             && a.minNear === b.minNear
+            && a.minFar === b.minFar
             && Vec3.exactEquals(a.position, b.position)
             && Vec3.exactEquals(a.up, b.up)
             && Vec3.exactEquals(a.target, b.target);
@@ -390,18 +394,14 @@ function updatePers(camera: Camera) {
 }
 
 function updateClip(camera: Camera) {
-    let { radius, radiusMax, mode, fog, clipFar, minNear } = camera.state;
+    let { radius, radiusMax, mode, fog, clipFar, minNear, minFar } = camera.state;
     if (radius < 0.01) radius = 0.01;
 
-    const normalizedFar = clipFar ? radius : radiusMax;
+    const normalizedFar = Math.max(clipFar ? radius : radiusMax, minFar);
     const cameraDistance = Vec3.distance(camera.position, camera.target);
     let near = cameraDistance - radius;
     let far = cameraDistance + normalizedFar;
 
-    const fogNearFactor = -(50 - fog) / 50;
-    const fogNear = cameraDistance - (normalizedFar * fogNearFactor);
-    const fogFar = far;
-
     if (mode === 'perspective') {
         // set at least to 5 to avoid slow sphere impostor rendering
         near = Math.max(Math.min(radiusMax, minNear), near);
@@ -417,8 +417,12 @@ function updateClip(camera: Camera) {
         far = near + 0.01;
     }
 
+    const fogNearFactor = -(50 - fog) / 50;
+    const fogNear = cameraDistance - (normalizedFar * fogNearFactor);
+    const fogFar = far;
+
     camera.near = near;
-    camera.far = 2 * far; // avoid precision issues distingushing far objects from background
+    camera.far = far;
     camera.fogNear = fogNear;
     camera.fogFar = fogFar;
 }

+ 2 - 2
src/mol-canvas3d/camera/util.ts

@@ -77,7 +77,7 @@ export function cameraProject(out: Vec4, point: Vec3, viewport: Viewport, projec
 
     // 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[1] = (tmpVec4[1] + 1) * height * 0.5 + y;
     out[2] = (tmpVec4[2] + 1) * 0.5;
     out[3] = w === 0 ? 0 : 1 / w;
     return out;
@@ -92,7 +92,7 @@ export function cameraUnproject(out: Vec3, point: Vec3 | Vec4, viewport: Viewpor
     const { x, y, width, height } = viewport;
 
     const px = point[0] - x;
-    const py = (height - point[1] - 1) - y;
+    const py = point[1] - y;
     const pz = point[2];
 
     out[0] = (2 * px) / width - 1;

+ 18 - 8
src/mol-canvas3d/canvas3d.ts

@@ -332,12 +332,12 @@ namespace Canvas3D {
         }, { x, y, width, height }, { pixelScale: attribs.pixelScale });
         const stereoCamera = new StereoCamera(camera, p.camera.stereo.params);
 
-        const controls = TrackballControls.create(input, camera, p.trackball);
+        const controls = TrackballControls.create(input, camera, scene, p.trackball);
         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 }, attribs.pickPadding);
-        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, p.interaction);
+        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, controls, p.interaction);
         const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
 
         passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
@@ -615,22 +615,32 @@ namespace Canvas3D {
         }
 
         function consoleStats() {
-            console.table(scene.renderables.map(r => ({
+            const items = scene.renderables.map(r => ({
                 drawCount: r.values.drawCount.ref.value,
                 instanceCount: r.values.instanceCount.ref.value,
                 materialId: r.materialId,
                 renderItemId: r.id,
-            })));
-            console.log(webgl.stats);
+            }));
+
+            console.groupCollapsed(`${items.length} RenderItems`);
+
+            if (items.length < 50) {
+                console.table(items);
+            } else {
+                console.log(items);
+            }
+            console.log(JSON.stringify(webgl.stats, undefined, 4));
 
             const { texture, attribute, elements } = webgl.resources.getByteCounts();
-            console.log({
+            console.log(JSON.stringify({
                 texture: `${(texture / 1024 / 1024).toFixed(3)} MiB`,
                 attribute: `${(attribute / 1024 / 1024).toFixed(3)} MiB`,
                 elements: `${(elements / 1024 / 1024).toFixed(3)} MiB`,
-            });
+            }, undefined, 4));
+
+            console.log(JSON.stringify(webgl.timer.formatedStats(), undefined, 4));
 
-            console.log(webgl.timer.formatedStats());
+            console.groupEnd();
         }
 
         function add(repr: Representation.Any) {

+ 424 - 40
src/mol-canvas3d/controls/trackball.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
@@ -10,20 +10,25 @@
 
 import { Quat, Vec2, Vec3, EPSILON } from '../../mol-math/linear-algebra';
 import { Viewport } from '../camera/util';
-import { InputObserver, DragInput, WheelInput, PinchInput, ButtonsType, ModifiersKeys, GestureInput } from '../../mol-util/input/input-observer';
+import { InputObserver, DragInput, WheelInput, PinchInput, ButtonsType, ModifiersKeys, GestureInput, KeyInput, MoveInput } from '../../mol-util/input/input-observer';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { Camera } from '../camera';
 import { absMax, degToRad } from '../../mol-math/misc';
 import { Binding } from '../../mol-util/binding';
+import { Scene } from '../../mol-gl/scene';
 
 const B = ButtonsType;
 const M = ModifiersKeys;
 const Trigger = Binding.Trigger;
+const Key = Binding.TriggerKey;
 
 export const DefaultTrackballBindings = {
     dragRotate: Binding([Trigger(B.Flag.Primary, M.create())], 'Rotate', 'Drag using ${triggers}'),
-    dragRotateZ: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Rotate around z-axis', 'Drag using ${triggers}'),
-    dragPan: Binding([Trigger(B.Flag.Secondary, M.create()), Trigger(B.Flag.Primary, M.create({ control: true }))], 'Pan', 'Drag using ${triggers}'),
+    dragRotateZ: Binding([Trigger(B.Flag.Primary, M.create({ shift: true, control: true }))], 'Rotate around z-axis (roll)', 'Drag using ${triggers}'),
+    dragPan: Binding([
+        Trigger(B.Flag.Secondary, M.create()),
+        Trigger(B.Flag.Primary, M.create({ control: true }))
+    ], 'Pan', 'Drag using ${triggers}'),
     dragZoom: Binding.Empty,
     dragFocus: Binding([Trigger(B.Flag.Forth, M.create())], 'Focus', 'Drag using ${triggers}'),
     dragFocusZoom: Binding([Trigger(B.Flag.Auxilary, M.create())], 'Focus and zoom', 'Drag using ${triggers}'),
@@ -31,6 +36,22 @@ export const DefaultTrackballBindings = {
     scrollZoom: Binding([Trigger(B.Flag.Auxilary, M.create())], 'Zoom', 'Scroll using ${triggers}'),
     scrollFocus: Binding([Trigger(B.Flag.Auxilary, M.create({ shift: true }))], 'Clip', 'Scroll using ${triggers}'),
     scrollFocusZoom: Binding.Empty,
+
+    keyMoveForward: Binding([Key('KeyW')], 'Move forward', 'Press ${triggers}'),
+    keyMoveBack: Binding([Key('KeyS')], 'Move back', 'Press ${triggers}'),
+    keyMoveLeft: Binding([Key('KeyA')], 'Move left', 'Press ${triggers}'),
+    keyMoveRight: Binding([Key('KeyD')], 'Move right', 'Press ${triggers}'),
+    keyMoveUp: Binding([Key('KeyR')], 'Move up', 'Press ${triggers}'),
+    keyMoveDown: Binding([Key('KeyF')], 'Move down', 'Press ${triggers}'),
+    keyRollLeft: Binding([Key('KeyQ')], 'Roll left', 'Press ${triggers}'),
+    keyRollRight: Binding([Key('KeyE')], 'Roll right', 'Press ${triggers}'),
+    keyPitchUp: Binding([Key('ArrowUp', M.create({ shift: true }))], 'Pitch up', 'Press ${triggers}'),
+    keyPitchDown: Binding([Key('ArrowDown', M.create({ shift: true }))], 'Pitch down', 'Press ${triggers}'),
+    keyYawLeft: Binding([Key('ArrowLeft', M.create({ shift: true }))], 'Yaw left', 'Press ${triggers}'),
+    keyYawRight: Binding([Key('ArrowRight', M.create({ shift: true }))], 'Yaw right', 'Press ${triggers}'),
+
+    boostMove: Binding([Key('ShiftLeft')], 'Boost move', 'Press ${triggers}'),
+    enablePointerLock: Binding([Key('Space', M.create({ control: true }))], 'Enable pointer lock', 'Press ${triggers}'),
 };
 
 export const TrackballControlsParams = {
@@ -39,6 +60,9 @@ export const TrackballControlsParams = {
     rotateSpeed: PD.Numeric(5.0, { min: 1, max: 10, step: 1 }),
     zoomSpeed: PD.Numeric(7.0, { min: 1, max: 15, step: 1 }),
     panSpeed: PD.Numeric(1.0, { min: 0.1, max: 5, step: 0.1 }),
+    moveSpeed: PD.Numeric(0.75, { min: 0.1, max: 3, step: 0.1 }),
+    boostMoveFactor: PD.Numeric(5.0, { min: 0.1, max: 10, step: 0.1 }),
+    flyMode: PD.Boolean(false),
 
     animate: PD.MappedStatic('off', {
         off: PD.EmptyGroup(),
@@ -82,6 +106,7 @@ export { TrackballControls };
 interface TrackballControls {
     readonly viewport: Viewport
     readonly isAnimating: boolean
+    readonly isMoving: boolean
 
     readonly props: Readonly<TrackballControlsProps>
     setProps: (props: Partial<TrackballControlsProps>) => void
@@ -92,8 +117,14 @@ interface TrackballControls {
     dispose: () => void
 }
 namespace TrackballControls {
-    export function create(input: InputObserver, camera: Camera, props: Partial<TrackballControlsProps> = {}): TrackballControls {
-        const p = { ...PD.getDefaultValues(TrackballControlsParams), ...props };
+    export function create(input: InputObserver, camera: Camera, scene: Scene, props: Partial<TrackballControlsProps> = {}): TrackballControls {
+        const p: TrackballControlsProps = {
+            ...PD.getDefaultValues(TrackballControlsParams),
+            ...props,
+            // include default bindings for backwards state compatibility
+            bindings: { ...DefaultTrackballBindings, ...props.bindings }
+        };
+        const b = p.bindings;
 
         const viewport = Viewport.clone(camera.viewport);
 
@@ -104,6 +135,11 @@ namespace TrackballControls {
         const wheelSub = input.wheel.subscribe(onWheel);
         const pinchSub = input.pinch.subscribe(onPinch);
         const gestureSub = input.gesture.subscribe(onGesture);
+        const keyDownSub = input.keyDown.subscribe(onKeyDown);
+        const keyUpSub = input.keyUp.subscribe(onKeyUp);
+        const moveSub = input.move.subscribe(onMove);
+        const lockSub = input.lock.subscribe(onLock);
+        const leaveSub = input.leave.subscribe(onLeave);
 
         let _isInteracting = false;
 
@@ -117,9 +153,12 @@ namespace TrackballControls {
         const _rotLastAxis = Vec3();
         let _rotLastAngle = 0;
 
-        const _zRotPrev = Vec2();
-        const _zRotCurr = Vec2();
-        let _zRotLastAngle = 0;
+        const _rollPrev = Vec2();
+        const _rollCurr = Vec2();
+        let _rollLastAngle = 0;
+
+        let _pitchLastAngle = 0;
+        let _yawLastAngle = 0;
 
         const _zoomStart = Vec2();
         const _zoomEnd = Vec2();
@@ -149,7 +188,7 @@ namespace TrackballControls {
             return Vec2.set(
                 mouseOnCircleVec2,
                 (pageX - viewport.width * 0.5 - viewport.x) / (viewport.width * 0.5),
-                (viewport.height + 2 * (viewport.y - pageY)) / viewport.width // screen.width intentional
+                (viewport.height + 2 * (viewport.y - pageY)) / viewport.width // viewport.width intentional
             );
         }
 
@@ -203,26 +242,74 @@ namespace TrackballControls {
             Vec2.copy(_rotPrev, _rotCurr);
         }
 
-        const zRotQuat = Quat();
+        const rollQuat = Quat();
+        const rollDir = Vec3();
 
-        function zRotateCamera() {
-            const dx = _zRotCurr[0] - _zRotPrev[0];
-            const dy = _zRotCurr[1] - _zRotPrev[1];
-            const angle = p.rotateSpeed * (-dx + dy) * -0.05;
+        function rollCamera() {
+            const k = (keyState.rollRight - keyState.rollLeft) / 45;
+            const dx = (_rollCurr[0] - _rollPrev[0]) * -Math.sign(_rollCurr[1]);
+            const dy = (_rollCurr[1] - _rollPrev[1]) * -Math.sign(_rollCurr[0]);
+            const angle = -p.rotateSpeed * (-dx + dy) + k;
 
             if (angle) {
-                Vec3.sub(_eye, camera.position, camera.target);
-                Quat.setAxisAngle(zRotQuat, _eye, angle);
-                Vec3.transformQuat(camera.up, camera.up, zRotQuat);
-                _zRotLastAngle = angle;
-            } else if (!p.staticMoving && _zRotLastAngle) {
-                _zRotLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
-                Vec3.sub(_eye, camera.position, camera.target);
-                Quat.setAxisAngle(zRotQuat, _eye, _zRotLastAngle);
-                Vec3.transformQuat(camera.up, camera.up, zRotQuat);
+                Vec3.normalize(rollDir, _eye);
+                Quat.setAxisAngle(rollQuat, rollDir, angle);
+                Vec3.transformQuat(camera.up, camera.up, rollQuat);
+                _rollLastAngle = angle;
+            } else if (!p.staticMoving && _rollLastAngle) {
+                _rollLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
+                Vec3.normalize(rollDir, _eye);
+                Quat.setAxisAngle(rollQuat, rollDir, _rollLastAngle);
+                Vec3.transformQuat(camera.up, camera.up, rollQuat);
             }
 
-            Vec2.copy(_zRotPrev, _zRotCurr);
+            Vec2.copy(_rollPrev, _rollCurr);
+        }
+
+        const pitchQuat = Quat();
+        const pitchDir = Vec3();
+
+        function pitchCamera() {
+            const m = (keyState.pitchUp - keyState.pitchDown) / (p.flyMode ? 360 : 90);
+            const angle = -p.rotateSpeed * m;
+
+            if (angle) {
+                Vec3.cross(pitchDir, _eye, camera.up);
+                Vec3.normalize(pitchDir, pitchDir);
+                Quat.setAxisAngle(pitchQuat, pitchDir, angle);
+                Vec3.transformQuat(_eye, _eye, pitchQuat);
+                Vec3.transformQuat(camera.up, camera.up, pitchQuat);
+                _pitchLastAngle = angle;
+            } else if (!p.staticMoving && _pitchLastAngle) {
+                _pitchLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
+                Vec3.cross(pitchDir, _eye, camera.up);
+                Vec3.normalize(pitchDir, pitchDir);
+                Quat.setAxisAngle(pitchQuat, pitchDir, _pitchLastAngle);
+                Vec3.transformQuat(_eye, _eye, pitchQuat);
+                Vec3.transformQuat(camera.up, camera.up, pitchQuat);
+            }
+        }
+
+        const yawQuat = Quat();
+        const yawDir = Vec3();
+
+        function yawCamera() {
+            const m = (keyState.yawRight - keyState.yawLeft) / (p.flyMode ? 360 : 90);
+            const angle = -p.rotateSpeed * m;
+
+            if (angle) {
+                Vec3.normalize(yawDir, camera.up);
+                Quat.setAxisAngle(yawQuat, yawDir, angle);
+                Vec3.transformQuat(_eye, _eye, yawQuat);
+                Vec3.transformQuat(camera.up, camera.up, yawQuat);
+                _yawLastAngle = angle;
+            } else if (!p.staticMoving && _yawLastAngle) {
+                _yawLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
+                Vec3.normalize(yawDir, camera.up);
+                Quat.setAxisAngle(yawQuat, yawDir, _yawLastAngle);
+                Vec3.transformQuat(_eye, _eye, yawQuat);
+                Vec3.transformQuat(camera.up, camera.up, yawQuat);
+            }
         }
 
         function zoomCamera() {
@@ -283,6 +370,92 @@ namespace TrackballControls {
             }
         }
 
+        const keyState = {
+            moveUp: 0, moveDown: 0, moveLeft: 0, moveRight: 0, moveForward: 0, moveBack: 0,
+            pitchUp: 0, pitchDown: 0, yawLeft: 0, yawRight: 0, rollLeft: 0, rollRight: 0,
+            boostMove: 0,
+        };
+
+        const moveDir = Vec3();
+        const moveEye = Vec3();
+
+        function moveCamera(deltaT: number) {
+            Vec3.sub(moveEye, camera.position, camera.target);
+            const minDistance = Math.max(camera.state.minNear, p.minDistance);
+            Vec3.setMagnitude(moveEye, moveEye, minDistance);
+
+            const moveSpeed = deltaT * (60 / 1000) * p.moveSpeed * (keyState.boostMove === 1 ? p.boostMoveFactor : 1);
+
+            if (keyState.moveForward === 1) {
+                Vec3.normalize(moveDir, moveEye);
+                Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                const dt = Vec3.distance(camera.target, camera.position);
+                const ds = Vec3.distance(scene.boundingSphereVisible.center, camera.position);
+                if (p.flyMode || input.pointerLock || (dt < minDistance && ds < camera.state.radiusMax)) {
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                }
+            }
+
+            if (keyState.moveBack === 1) {
+                Vec3.normalize(moveDir, moveEye);
+                Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                }
+            }
+
+            if (keyState.moveLeft === 1) {
+                Vec3.cross(moveDir, moveEye, camera.up);
+                Vec3.normalize(moveDir, moveDir);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (keyState.moveRight === 1) {
+                Vec3.cross(moveDir, moveEye, camera.up);
+                Vec3.normalize(moveDir, moveDir);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (keyState.moveUp === 1) {
+                Vec3.normalize(moveDir, camera.up);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (keyState.moveDown === 1) {
+                Vec3.normalize(moveDir, camera.up);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (p.flyMode || input.pointerLock) {
+                const cameraDistance = Vec3.distance(camera.position, scene.boundingSphereVisible.center);
+                camera.setState({ minFar: cameraDistance + scene.boundingSphereVisible.radius });
+            }
+        }
+
         /**
          * Ensure the distance between object and target is within the min/max distance
          * and not too large compared to `camera.state.radiusMax`
@@ -319,15 +492,19 @@ namespace TrackballControls {
         /** Update the object's position, direction and up vectors */
         function update(t: number) {
             if (lastUpdated === t) return;
+
+            const deltaT = t - lastUpdated;
             if (lastUpdated > 0) {
-                if (p.animate.name === 'spin') spin(t - lastUpdated);
-                else if (p.animate.name === 'rock') rock(t - lastUpdated);
+                if (p.animate.name === 'spin') spin(deltaT);
+                else if (p.animate.name === 'rock') rock(deltaT);
             }
 
             Vec3.sub(_eye, camera.position, camera.target);
 
             rotateCamera();
-            zRotateCamera();
+            rollCamera();
+            pitchCamera();
+            yawCamera();
             zoomCamera();
             focusCamera();
             panCamera();
@@ -335,6 +512,15 @@ namespace TrackballControls {
             Vec3.add(camera.position, camera.target, _eye);
             checkDistances();
 
+            if (lastUpdated > 0) {
+                // clamp the maximum step size at 15 frames to avoid too big jumps
+                // TODO: make this a parameter?
+                moveCamera(Math.min(deltaT, 15 * 1000 / 60));
+            }
+
+            Vec3.sub(_eye, camera.position, camera.target);
+            checkDistances();
+
             if (Vec3.squaredDistance(lastPosition, camera.position) > EPSILON) {
                 Vec3.copy(lastPosition, camera.position);
             }
@@ -363,24 +549,28 @@ namespace TrackballControls {
             _isInteracting = true;
             resetRock(); // start rocking from the center after interactions
 
-            const dragRotate = Binding.match(p.bindings.dragRotate, buttons, modifiers);
-            const dragRotateZ = Binding.match(p.bindings.dragRotateZ, buttons, modifiers);
-            const dragPan = Binding.match(p.bindings.dragPan, buttons, modifiers);
-            const dragZoom = Binding.match(p.bindings.dragZoom, buttons, modifiers);
-            const dragFocus = Binding.match(p.bindings.dragFocus, buttons, modifiers);
-            const dragFocusZoom = Binding.match(p.bindings.dragFocusZoom, buttons, modifiers);
+            const dragRotate = Binding.match(b.dragRotate, buttons, modifiers);
+            const dragRotateZ = Binding.match(b.dragRotateZ, buttons, modifiers);
+            const dragPan = Binding.match(b.dragPan, buttons, modifiers);
+            const dragZoom = Binding.match(b.dragZoom, buttons, modifiers);
+            const dragFocus = Binding.match(b.dragFocus, buttons, modifiers);
+            const dragFocusZoom = Binding.match(b.dragFocusZoom, buttons, modifiers);
 
             getMouseOnCircle(pageX, pageY);
             getMouseOnScreen(pageX, pageY);
 
+            const pr = input.pixelRatio;
+            const vx = (x * pr - viewport.width / 2 - viewport.x) / viewport.width;
+            const vy = -(input.height - y * pr - viewport.height / 2 - viewport.y) / viewport.height;
+
             if (isStart) {
                 if (dragRotate) {
                     Vec2.copy(_rotCurr, mouseOnCircleVec2);
                     Vec2.copy(_rotPrev, _rotCurr);
                 }
                 if (dragRotateZ) {
-                    Vec2.copy(_zRotCurr, mouseOnCircleVec2);
-                    Vec2.copy(_zRotPrev, _zRotCurr);
+                    Vec2.set(_rollCurr, vx, vy);
+                    Vec2.copy(_rollPrev, _rollCurr);
                 }
                 if (dragZoom || dragFocusZoom) {
                     Vec2.copy(_zoomStart, mouseOnScreenVec2);
@@ -397,7 +587,7 @@ namespace TrackballControls {
             }
 
             if (dragRotate) Vec2.copy(_rotCurr, mouseOnCircleVec2);
-            if (dragRotateZ) Vec2.copy(_zRotCurr, mouseOnCircleVec2);
+            if (dragRotateZ) Vec2.set(_rollCurr, vx, vy);
             if (dragZoom || dragFocusZoom) Vec2.copy(_zoomEnd, mouseOnScreenVec2);
             if (dragFocus) Vec2.copy(_focusEnd, mouseOnScreenVec2);
             if (dragFocusZoom) {
@@ -418,16 +608,16 @@ namespace TrackballControls {
             if (delta < -p.maxWheelDelta) delta = -p.maxWheelDelta;
             else if (delta > p.maxWheelDelta) delta = p.maxWheelDelta;
 
-            if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
+            if (Binding.match(b.scrollZoom, buttons, modifiers)) {
                 _zoomEnd[1] += delta;
             }
-            if (Binding.match(p.bindings.scrollFocus, buttons, modifiers)) {
+            if (Binding.match(b.scrollFocus, buttons, modifiers)) {
                 _focusEnd[1] += delta;
             }
         }
 
         function onPinch({ fractionDelta, buttons, modifiers }: PinchInput) {
-            if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
+            if (Binding.match(b.scrollZoom, buttons, modifiers)) {
                 _isInteracting = true;
                 _zoomEnd[1] += p.gestureScaleFactor * fractionDelta;
             }
@@ -438,6 +628,177 @@ namespace TrackballControls {
             _zoomEnd[1] += p.gestureScaleFactor * deltaScale;
         }
 
+        function onMove({ movementX, movementY }: MoveInput) {
+            if (!input.pointerLock || movementX === undefined || movementY === undefined) return;
+
+            const cx = viewport.width * 0.5 - viewport.x;
+            const cy = viewport.height * 0.5 - viewport.y;
+
+            Vec2.copy(_rotPrev, getMouseOnCircle(cx, cy));
+            Vec2.copy(_rotCurr, getMouseOnCircle(movementX + cx, movementY + cy));
+        }
+
+        function onKeyDown({ modifiers, code, x, y }: KeyInput) {
+            if (outsideViewport(x, y)) return;
+
+            if (Binding.matchKey(b.keyMoveForward, code, modifiers)) {
+                keyState.moveForward = 1;
+            } else if (Binding.matchKey(b.keyMoveBack, code, modifiers)) {
+                keyState.moveBack = 1;
+            } else if (Binding.matchKey(b.keyMoveLeft, code, modifiers)) {
+                keyState.moveLeft = 1;
+            } else if (Binding.matchKey(b.keyMoveRight, code, modifiers)) {
+                keyState.moveRight = 1;
+            } else if (Binding.matchKey(b.keyMoveUp, code, modifiers)) {
+                keyState.moveUp = 1;
+            } else if (Binding.matchKey(b.keyMoveDown, code, modifiers)) {
+                keyState.moveDown = 1;
+            } else if (Binding.matchKey(b.keyRollLeft, code, modifiers)) {
+                keyState.rollLeft = 1;
+            } else if (Binding.matchKey(b.keyRollRight, code, modifiers)) {
+                keyState.rollRight = 1;
+            } else if (Binding.matchKey(b.keyPitchUp, code, modifiers)) {
+                keyState.pitchUp = 1;
+            } else if (Binding.matchKey(b.keyPitchDown, code, modifiers)) {
+                keyState.pitchDown = 1;
+            } else if (Binding.matchKey(b.keyYawLeft, code, modifiers)) {
+                keyState.yawLeft = 1;
+            } else if (Binding.matchKey(b.keyYawRight, code, modifiers)) {
+                keyState.yawRight = 1;
+            }
+
+            if (Binding.matchKey(b.boostMove, code, modifiers)) {
+                keyState.boostMove = 1;
+            }
+
+            if (Binding.matchKey(b.enablePointerLock, code, modifiers)) {
+                input.requestPointerLock(viewport);
+            }
+        }
+
+        function onKeyUp({ modifiers, code, x, y }: KeyInput) {
+            if (outsideViewport(x, y)) return;
+
+            let isModifierCode = false;
+
+            if (code.startsWith('Alt')) {
+                isModifierCode = true;
+                modifiers.alt = true;
+            } else if (code.startsWith('Shift')) {
+                isModifierCode = true;
+                modifiers.shift = true;
+            } else if (code.startsWith('Control')) {
+                isModifierCode = true;
+                modifiers.control = true;
+            } else if (code.startsWith('Meta')) {
+                isModifierCode = true;
+                modifiers.meta = true;
+            }
+
+            const codes = [];
+
+            if (isModifierCode) {
+                if (keyState.moveForward) codes.push(b.keyMoveForward.triggers[0]?.code || '');
+                if (keyState.moveBack) codes.push(b.keyMoveBack.triggers[0]?.code || '');
+                if (keyState.moveLeft) codes.push(b.keyMoveLeft.triggers[0]?.code || '');
+                if (keyState.moveRight) codes.push(b.keyMoveRight.triggers[0]?.code || '');
+                if (keyState.moveUp) codes.push(b.keyMoveUp.triggers[0]?.code || '');
+                if (keyState.moveDown) codes.push(b.keyMoveDown.triggers[0]?.code || '');
+                if (keyState.rollLeft) codes.push(b.keyRollLeft.triggers[0]?.code || '');
+                if (keyState.rollRight) codes.push(b.keyRollRight.triggers[0]?.code || '');
+                if (keyState.pitchUp) codes.push(b.keyPitchUp.triggers[0]?.code || '');
+                if (keyState.pitchDown) codes.push(b.keyPitchDown.triggers[0]?.code || '');
+                if (keyState.yawLeft) codes.push(b.keyYawLeft.triggers[0]?.code || '');
+                if (keyState.yawRight) codes.push(b.keyYawRight.triggers[0]?.code || '');
+            } else {
+                codes.push(code);
+            }
+
+            for (const code of codes) {
+                if (Binding.matchKey(b.keyMoveForward, code, modifiers)) {
+                    keyState.moveForward = 0;
+                } else if (Binding.matchKey(b.keyMoveBack, code, modifiers)) {
+                    keyState.moveBack = 0;
+                } else if (Binding.matchKey(b.keyMoveLeft, code, modifiers)) {
+                    keyState.moveLeft = 0;
+                } else if (Binding.matchKey(b.keyMoveRight, code, modifiers)) {
+                    keyState.moveRight = 0;
+                } else if (Binding.matchKey(b.keyMoveUp, code, modifiers)) {
+                    keyState.moveUp = 0;
+                } else if (Binding.matchKey(b.keyMoveDown, code, modifiers)) {
+                    keyState.moveDown = 0;
+                } else if (Binding.matchKey(b.keyRollLeft, code, modifiers)) {
+                    keyState.rollLeft = 0;
+                } else if (Binding.matchKey(b.keyRollRight, code, modifiers)) {
+                    keyState.rollRight = 0;
+                } else if (Binding.matchKey(b.keyPitchUp, code, modifiers)) {
+                    keyState.pitchUp = 0;
+                } else if (Binding.matchKey(b.keyPitchDown, code, modifiers)) {
+                    keyState.pitchDown = 0;
+                } else if (Binding.matchKey(b.keyYawLeft, code, modifiers)) {
+                    keyState.yawLeft = 0;
+                } else if (Binding.matchKey(b.keyYawRight, code, modifiers)) {
+                    keyState.yawRight = 0;
+                }
+            }
+
+            if (Binding.matchKey(b.boostMove, code, modifiers)) {
+                keyState.boostMove = 0;
+            }
+        }
+
+        function initCameraMove() {
+            Vec3.sub(moveEye, camera.position, camera.target);
+            const minDistance = Math.max(camera.state.minNear, p.minDistance);
+            Vec3.setMagnitude(moveEye, moveEye, minDistance);
+            Vec3.sub(camera.target, camera.position, moveEye);
+
+            const cameraDistance = Vec3.distance(camera.position, scene.boundingSphereVisible.center);
+            camera.setState({ minFar: cameraDistance + scene.boundingSphereVisible.radius });
+        }
+
+        function resetCameraMove() {
+            const { center, radius } = scene.boundingSphereVisible;
+            const cameraDistance = Vec3.distance(camera.position, center);
+            if (cameraDistance > radius) {
+                const focus = camera.getFocus(center, radius);
+                camera.setState({ ...focus, minFar: 0 });
+            } else {
+                camera.setState({
+                    minFar: 0,
+                    radius: scene.boundingSphereVisible.radius,
+                });
+            }
+        }
+
+        function onLock(isLocked: boolean) {
+            if (isLocked) {
+                initCameraMove();
+            } else {
+                resetCameraMove();
+            }
+        }
+
+        function unsetKeyState() {
+            keyState.moveForward = 0;
+            keyState.moveBack = 0;
+            keyState.moveLeft = 0;
+            keyState.moveRight = 0;
+            keyState.moveUp = 0;
+            keyState.moveDown = 0;
+            keyState.rollLeft = 0;
+            keyState.rollRight = 0;
+            keyState.pitchUp = 0;
+            keyState.pitchDown = 0;
+            keyState.yawLeft = 0;
+            keyState.yawRight = 0;
+            keyState.boostMove = 0;
+        }
+
+        function onLeave() {
+            unsetKeyState();
+        }
+
         function dispose() {
             if (disposed) return;
             disposed = true;
@@ -447,6 +808,11 @@ namespace TrackballControls {
             pinchSub.unsubscribe();
             gestureSub.unsubscribe();
             interactionEndSub.unsubscribe();
+            keyDownSub.unsubscribe();
+            keyUpSub.unsubscribe();
+            moveSub.unsubscribe();
+            lockSub.unsubscribe();
+            leaveSub.unsubscribe();
         }
 
         const _spinSpeed = Vec2.create(0.005, 0);
@@ -489,13 +855,31 @@ namespace TrackballControls {
         return {
             viewport,
             get isAnimating() { return p.animate.name !== 'off'; },
+            get isMoving() {
+                return (
+                    keyState.moveForward === 1 || keyState.moveBack === 1 ||
+                    keyState.moveLeft === 1 || keyState.moveRight === 1 ||
+                    keyState.moveUp === 1 || keyState.moveDown === 1 ||
+                    keyState.rollLeft === 1 || keyState.rollRight === 1 ||
+                    keyState.pitchUp === 1 || keyState.pitchDown === 1 ||
+                    keyState.yawLeft === 1 || keyState.yawRight === 1
+                );
+            },
 
             get props() { return p as Readonly<TrackballControlsProps>; },
             setProps: (props: Partial<TrackballControlsProps>) => {
                 if (props.animate?.name === 'rock' && p.animate.name !== 'rock') {
                     resetRock(); // start rocking from the center
                 }
+                if (props.flyMode !== undefined && props.flyMode !== p.flyMode) {
+                    if (props.flyMode) {
+                        initCameraMove();
+                    } else {
+                        resetCameraMove();
+                    }
+                }
                 Object.assign(p, props);
+                Object.assign(b, props.bindings);
             },
 
             start,

+ 9 - 4
src/mol-canvas3d/helper/interaction-events.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
@@ -13,6 +13,7 @@ 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';
+import { TrackballControls } from '../controls/trackball';
 
 type Canvas3D = import('../canvas3d').Canvas3D
 type HoverEvent = import('../canvas3d').Canvas3D.HoverEvent
@@ -68,7 +69,7 @@ export class Canvas3dInteractionHelper {
     }
 
     private identify(e: InputEvent, t: number) {
-        const xyChanged = this.startX !== this.endX || this.startY !== this.endY;
+        const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
 
         if (e === InputEvent.Drag) {
             if (xyChanged && !this.outsideViewport(this.startX, this.startY)) {
@@ -188,7 +189,7 @@ export class Canvas3dInteractionHelper {
         this.ev.dispose();
     }
 
-    constructor(private canvasIdentify: Canvas3D['identify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, props: Partial<Canvas3dInteractionHelperProps> = {}) {
+    constructor(private canvasIdentify: Canvas3D['identify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private controls: TrackballControls, props: Partial<Canvas3dInteractionHelperProps> = {}) {
         this.props = { ...PD.getDefaultValues(Canvas3dInteractionHelperParams), ...props };
 
         input.drag.subscribe(({ x, y, buttons, button, modifiers }) => {
@@ -197,8 +198,12 @@ export class Canvas3dInteractionHelper {
             this.drag(x, y, buttons, button, modifiers);
         });
 
-        input.move.subscribe(({ x, y, inside, buttons, button, modifiers }) => {
+        input.move.subscribe(({ x, y, inside, buttons, button, modifiers, onElement }) => {
             if (!inside || this.isInteracting) return;
+            if (!onElement) {
+                this.leave();
+                return;
+            }
             // console.log('move');
             this.move(x, y, buttons, button, modifiers);
         });

+ 1 - 1
src/mol-canvas3d/passes/marking.ts

@@ -106,7 +106,7 @@ export class MarkingPass {
         const { highlightEdgeColor, selectEdgeColor, edgeScale, innerEdgeFactor, ghostEdgeStrength, highlightEdgeStrength, selectEdgeStrength } = props;
 
         const { values: edgeValues } = this.edge;
-        const _edgeScale = Math.round(edgeScale * this.webgl.pixelRatio);
+        const _edgeScale = Math.max(1, Math.round(edgeScale * this.webgl.pixelRatio));
         if (edgeValues.dEdgeScale.ref.value !== _edgeScale) {
             ValueCell.update(edgeValues.dEdgeScale, _edgeScale);
             this.edge.update();

+ 1 - 1
src/mol-canvas3d/passes/pick.ts

@@ -358,7 +358,7 @@ export class PickHelper {
 
         const z = this.getDepth(xp, yp);
         // console.log('z', z);
-        const position = Vec3.create(x, viewport.height - y, z);
+        const position = Vec3.create(x, y, z);
         if (StereoCamera.is(camera)) {
             const halfWidth = Math.floor(viewport.width / 2);
             if (x > viewport.x + halfWidth) {

+ 229 - 75
src/mol-canvas3d/passes/postprocessing.ts

@@ -11,7 +11,7 @@ import { TextureSpec, Values, UniformSpec, DefineSpec } from '../../mol-gl/rende
 import { ShaderCode } from '../../mol-gl/shader-code';
 import { WebGLContext } from '../../mol-gl/webgl/context';
 import { Texture } from '../../mol-gl/webgl/texture';
-import { ValueCell } from '../../mol-util';
+import { deepEqual, ValueCell } from '../../mol-util';
 import { createComputeRenderItem } from '../../mol-gl/webgl/render-item';
 import { createComputeRenderable, ComputeRenderable } from '../../mol-gl/renderable';
 import { Mat4, Vec2, Vec3, Vec4 } from '../../mol-math/linear-algebra';
@@ -43,9 +43,9 @@ const OutlinesSchema = {
     dOrthographic: DefineSpec('number'),
     uNear: UniformSpec('f'),
     uFar: UniformSpec('f'),
+    uInvProjection: UniformSpec('m4'),
 
-    uMaxPossibleViewZDiff: UniformSpec('f'),
-
+    uOutlineThreshold: UniformSpec('f'),
     dTransparentOutline: DefineSpec('boolean'),
 };
 type OutlinesRenderable = ComputeRenderable<Values<typeof OutlinesSchema>>
@@ -63,9 +63,9 @@ function getOutlinesRenderable(ctx: WebGLContext, depthTextureOpaque: Texture, d
         dOrthographic: ValueCell.create(0),
         uNear: ValueCell.create(1),
         uFar: ValueCell.create(10000),
+        uInvProjection: ValueCell.create(Mat4.identity()),
 
-        uMaxPossibleViewZDiff: ValueCell.create(0.5),
-
+        uOutlineThreshold: ValueCell.create(0.33),
         dTransparentOutline: ValueCell.create(transparentOutline),
     };
 
@@ -137,6 +137,8 @@ function getShadowsRenderable(ctx: WebGLContext, depthTexture: Texture): Shadows
 const SsaoSchema = {
     ...QuadSchema,
     tDepth: TextureSpec('texture', 'rgba', 'ubyte', 'nearest'),
+    tDepthHalf: TextureSpec('texture', 'rgba', 'ubyte', 'nearest'),
+    tDepthQuarter: TextureSpec('texture', 'rgba', 'ubyte', 'nearest'),
 
     uSamples: UniformSpec('v3[]'),
     dNSamples: DefineSpec('number'),
@@ -149,14 +151,23 @@ const SsaoSchema = {
 
     uRadius: UniformSpec('f'),
     uBias: UniformSpec('f'),
+
+    dMultiScale: DefineSpec('boolean'),
+    dLevels: DefineSpec('number'),
+    uLevelRadius: UniformSpec('f[]'),
+    uLevelBias: UniformSpec('f[]'),
+    uNearThreshold: UniformSpec('f'),
+    uFarThreshold: UniformSpec('f'),
 };
 
 type SsaoRenderable = ComputeRenderable<Values<typeof SsaoSchema>>
 
-function getSsaoRenderable(ctx: WebGLContext, depthTexture: Texture): SsaoRenderable {
+function getSsaoRenderable(ctx: WebGLContext, depthTexture: Texture, depthHalfTexture: Texture, depthQuarterTexture: Texture): SsaoRenderable {
     const values: Values<typeof SsaoSchema> = {
         ...QuadValues,
         tDepth: ValueCell.create(depthTexture),
+        tDepthHalf: ValueCell.create(depthHalfTexture),
+        tDepthQuarter: ValueCell.create(depthQuarterTexture),
 
         uSamples: ValueCell.create(getSamples(32)),
         dNSamples: ValueCell.create(32),
@@ -167,8 +178,15 @@ function getSsaoRenderable(ctx: WebGLContext, depthTexture: Texture): SsaoRender
 
         uTexSize: ValueCell.create(Vec2.create(ctx.gl.drawingBufferWidth, ctx.gl.drawingBufferHeight)),
 
-        uRadius: ValueCell.create(8.0),
-        uBias: ValueCell.create(0.025),
+        uRadius: ValueCell.create(Math.pow(2, 5)),
+        uBias: ValueCell.create(0.8),
+
+        dMultiScale: ValueCell.create(false),
+        dLevels: ValueCell.create(3),
+        uLevelRadius: ValueCell.create([Math.pow(2, 2), Math.pow(2, 5), Math.pow(2, 8)]),
+        uLevelBias: ValueCell.create([0.8, 0.8, 0.8]),
+        uNearThreshold: ValueCell.create(10.0),
+        uFarThreshold: ValueCell.create(1500.0),
     };
 
     const schema = { ...SsaoSchema };
@@ -189,8 +207,7 @@ const SsaoBlurSchema = {
     uBlurDirectionX: UniformSpec('f'),
     uBlurDirectionY: UniformSpec('f'),
 
-    uMaxPossibleViewZDiff: UniformSpec('f'),
-
+    uInvProjection: UniformSpec('m4'),
     uNear: UniformSpec('f'),
     uFar: UniformSpec('f'),
     uBounds: UniformSpec('v4'),
@@ -211,8 +228,7 @@ function getSsaoBlurRenderable(ctx: WebGLContext, ssaoDepthTexture: Texture, dir
         uBlurDirectionX: ValueCell.create(direction === 'horizontal' ? 1 : 0),
         uBlurDirectionY: ValueCell.create(direction === 'vertical' ? 1 : 0),
 
-        uMaxPossibleViewZDiff: ValueCell.create(0.5),
-
+        uInvProjection: ValueCell.create(Mat4.identity()),
         uNear: ValueCell.create(0.0),
         uFar: ValueCell.create(10000.0),
         uBounds: ValueCell.create(Vec4()),
@@ -280,11 +296,9 @@ const PostprocessingSchema = {
     uFogFar: UniformSpec('f'),
     uFogColor: UniformSpec('v3'),
     uOutlineColor: UniformSpec('v3'),
+    uOcclusionColor: UniformSpec('v3'),
     uTransparentBackground: UniformSpec('b'),
 
-    uMaxPossibleViewZDiff: UniformSpec('f'),
-    uInvProjection: UniformSpec('m4'),
-
     dOcclusionEnable: DefineSpec('boolean'),
     uOcclusionOffset: UniformSpec('v2'),
 
@@ -292,13 +306,10 @@ const PostprocessingSchema = {
 
     dOutlineEnable: DefineSpec('boolean'),
     dOutlineScale: DefineSpec('number'),
-    uOutlineThreshold: UniformSpec('f'),
-
     dTransparentOutline: DefineSpec('boolean'),
 };
 type PostprocessingRenderable = ComputeRenderable<Values<typeof PostprocessingSchema>>
 
-
 function getPostprocessingRenderable(ctx: WebGLContext, colorTexture: Texture, depthTextureOpaque: Texture, depthTextureTransparent: Texture, shadowsTexture: Texture, outlinesTexture: Texture, ssaoDepthTexture: Texture, transparentOutline: boolean): PostprocessingRenderable {
     const values: Values<typeof PostprocessingSchema> = {
         ...QuadValues,
@@ -317,11 +328,9 @@ function getPostprocessingRenderable(ctx: WebGLContext, colorTexture: Texture, d
         uFogFar: ValueCell.create(10000),
         uFogColor: ValueCell.create(Vec3.create(1, 1, 1)),
         uOutlineColor: ValueCell.create(Vec3.create(0, 0, 0)),
+        uOcclusionColor: ValueCell.create(Vec3.create(0, 0, 0)),
         uTransparentBackground: ValueCell.create(false),
 
-        uMaxPossibleViewZDiff: ValueCell.create(0.5),
-        uInvProjection: ValueCell.create(Mat4.identity()),
-
         dOcclusionEnable: ValueCell.create(true),
         uOcclusionOffset: ValueCell.create(Vec2.create(0, 0)),
 
@@ -329,8 +338,6 @@ function getPostprocessingRenderable(ctx: WebGLContext, colorTexture: Texture, d
 
         dOutlineEnable: ValueCell.create(false),
         dOutlineScale: ValueCell.create(1),
-        uOutlineThreshold: ValueCell.create(0.33),
-
         dTransparentOutline: ValueCell.create(transparentOutline),
     };
 
@@ -345,10 +352,27 @@ export const PostprocessingParams = {
     occlusion: PD.MappedStatic('on', {
         on: PD.Group({
             samples: PD.Numeric(32, { min: 1, max: 256, step: 1 }),
-            radius: PD.Numeric(5, { min: 0, max: 10, step: 0.1 }, { description: 'Final occlusion radius is 2^x' }),
+            multiScale: PD.MappedStatic('off', {
+                on: PD.Group({
+                    levels: PD.ObjectList({
+                        radius: PD.Numeric(5, { min: 0, max: 20, step: 0.1 }, { description: 'Final occlusion radius is 2^x' }),
+                        bias: PD.Numeric(1, { min: 0, max: 3, step: 0.1 }),
+                    }, o => `${o.radius}, ${o.bias}`, { defaultValue: [
+                        { radius: 2, bias: 1 },
+                        { radius: 5, bias: 1 },
+                        { radius: 8, bias: 1 },
+                        { radius: 11, bias: 1 },
+                    ] }),
+                    nearThreshold: PD.Numeric(10, { min: 0, max: 50, step: 1 }),
+                    farThreshold: PD.Numeric(1500, { min: 0, max: 10000, step: 100 }),
+                }),
+                off: PD.Group({})
+            }, { cycle: true }),
+            radius: PD.Numeric(5, { min: 0, max: 20, step: 0.1 }, { description: 'Final occlusion radius is 2^x', hideIf: p => p?.multiScale.name === 'on' }),
             bias: PD.Numeric(0.8, { min: 0, max: 3, step: 0.1 }),
             blurKernelSize: PD.Numeric(15, { min: 1, max: 25, step: 2 }),
             resolutionScale: PD.Numeric(1, { min: 0.1, max: 1, step: 0.05 }, { description: 'Adjust resolution of occlusion calculation' }),
+            color: PD.Color(Color(0x000000)),
         }),
         off: PD.Group({})
     }, { cycle: true, description: 'Darken occluded crevices with the ambient occlusion effect' }),
@@ -380,6 +404,27 @@ export const PostprocessingParams = {
 
 export type PostprocessingProps = PD.Values<typeof PostprocessingParams>
 
+type Levels = {
+    count: number
+    radius: number[]
+    bias: number[]
+}
+
+function getLevels(props: { radius: number, bias: number }[], levels?: Levels): Levels {
+    const count = props.length;
+    const { radius, bias } = levels || {
+        radius: (new Array(count * 3)).fill(0),
+        bias: (new Array(count * 3)).fill(0),
+    };
+    props = props.slice().sort((a, b) => a.radius - b.radius);
+    for (let i = 0; i < count; ++i) {
+        const p = props[i];
+        radius[i] = Math.pow(2, p.radius);
+        bias[i] = p.bias;
+    }
+    return { count, radius, bias };
+}
+
 export class PostprocessingPass {
     static isEnabled(props: PostprocessingProps) {
         return props.occlusion.name === 'on' || props.shadow.name === 'on' || props.outline.name === 'on' || props.background.variant.name !== 'off';
@@ -404,6 +449,12 @@ export class PostprocessingPass {
     private readonly downsampledDepthTarget: RenderTarget;
     private readonly downsampleDepthRenderable: CopyRenderable;
 
+    private readonly depthHalfTarget: RenderTarget;
+    private readonly depthHalfRenderable: CopyRenderable;
+
+    private readonly depthQuarterTarget: RenderTarget;
+    private readonly depthQuarterRenderable: CopyRenderable;
+
     private readonly ssaoDepthTexture: Texture;
     private readonly ssaoDepthBlurProxyTexture: Texture;
 
@@ -423,6 +474,8 @@ export class PostprocessingPass {
         return Math.min(1, 1 / this.webgl.pixelRatio) * this.downsampleFactor;
     }
 
+    private levels: { radius: number, bias: number }[];
+
     private readonly bgColor = Vec3();
     readonly background: BackgroundPass;
 
@@ -435,6 +488,7 @@ export class PostprocessingPass {
         this.blurKernelSize = 1;
         this.downsampleFactor = 1;
         this.ssaoScale = this.calcSsaoScale();
+        this.levels = [];
 
         // needs to be linear for anti-aliasing pass
         this.target = webgl.createRenderTarget(width, height, false, 'uint8', 'linear');
@@ -452,11 +506,27 @@ export class PostprocessingPass {
         const sw = Math.floor(width * this.ssaoScale);
         const sh = Math.floor(height * this.ssaoScale);
 
+        const hw = Math.max(1, Math.floor(sw * 0.5));
+        const hh = Math.max(1, Math.floor(sh * 0.5));
+
+        const qw = Math.max(1, Math.floor(sw * 0.25));
+        const qh = Math.max(1, Math.floor(sh * 0.25));
+
         this.downsampledDepthTarget = drawPass.packedDepth
             ? webgl.createRenderTarget(sw, sh, false, 'uint8', 'linear', 'rgba')
             : webgl.createRenderTarget(sw, sh, false, 'float32', 'linear', webgl.isWebGL2 ? 'alpha' : 'rgba');
         this.downsampleDepthRenderable = createCopyRenderable(webgl, depthTextureOpaque);
 
+        this.depthHalfTarget = drawPass.packedDepth
+            ? webgl.createRenderTarget(hw, hh, false, 'uint8', 'linear', 'rgba')
+            : webgl.createRenderTarget(hw, hh, false, 'float32', 'linear', webgl.isWebGL2 ? 'alpha' : 'rgba');
+        this.depthHalfRenderable = createCopyRenderable(webgl, this.ssaoScale === 1 ? depthTextureOpaque : this.downsampledDepthTarget.texture);
+
+        this.depthQuarterTarget = drawPass.packedDepth
+            ? webgl.createRenderTarget(qw, qh, false, 'uint8', 'linear', 'rgba')
+            : webgl.createRenderTarget(qw, qh, false, 'float32', 'linear', webgl.isWebGL2 ? 'alpha' : 'rgba');
+        this.depthQuarterRenderable = createCopyRenderable(webgl, this.depthHalfTarget.texture);
+
         this.ssaoDepthTexture = webgl.resources.texture('image-uint8', 'rgba', 'ubyte', 'linear');
         this.ssaoDepthTexture.define(sw, sh);
         this.ssaoDepthTexture.attachFramebuffer(this.ssaoFramebuffer, 'color0');
@@ -467,7 +537,7 @@ export class PostprocessingPass {
 
         this.ssaoDepthTexture.attachFramebuffer(this.ssaoBlurSecondPassFramebuffer, 'color0');
 
-        this.ssaoRenderable = getSsaoRenderable(webgl, this.ssaoScale === 1 ? depthTextureOpaque : this.downsampledDepthTarget.texture);
+        this.ssaoRenderable = getSsaoRenderable(webgl, this.ssaoScale === 1 ? depthTextureOpaque : this.downsampledDepthTarget.texture, this.depthHalfTarget.texture, this.depthQuarterTarget.texture);
         this.ssaoBlurFirstPassRenderable = getSsaoBlurRenderable(webgl, this.ssaoDepthTexture, 'horizontal');
         this.ssaoBlurSecondPassRenderable = getSsaoBlurRenderable(webgl, this.ssaoDepthBlurProxyTexture, 'vertical');
         this.renderable = getPostprocessingRenderable(webgl, colorTarget.texture, depthTextureOpaque, depthTextureTransparent, this.shadowsTarget.texture, this.outlinesTarget.texture, this.ssaoDepthTexture, true);
@@ -482,19 +552,30 @@ export class PostprocessingPass {
         if (width !== w || height !== h || this.ssaoScale !== ssaoScale) {
             this.ssaoScale = ssaoScale;
 
-            const sw = Math.floor(width * this.ssaoScale);
-            const sh = Math.floor(height * this.ssaoScale);
             this.target.setSize(width, height);
             this.outlinesTarget.setSize(width, height);
             this.shadowsTarget.setSize(width, height);
+
+            const sw = Math.floor(width * this.ssaoScale);
+            const sh = Math.floor(height * this.ssaoScale);
             this.downsampledDepthTarget.setSize(sw, sh);
             this.ssaoDepthTexture.define(sw, sh);
             this.ssaoDepthBlurProxyTexture.define(sw, sh);
 
+            const hw = Math.max(1, Math.floor(sw * 0.5));
+            const hh = Math.max(1, Math.floor(sh * 0.5));
+            this.depthHalfTarget.setSize(hw, hh);
+
+            const qw = Math.max(1, Math.floor(sw * 0.25));
+            const qh = Math.max(1, Math.floor(sh * 0.25));
+            this.depthQuarterTarget.setSize(qw, qh);
+
             ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
             ValueCell.update(this.outlinesRenderable.values.uTexSize, Vec2.set(this.outlinesRenderable.values.uTexSize.ref.value, width, height));
             ValueCell.update(this.shadowsRenderable.values.uTexSize, Vec2.set(this.shadowsRenderable.values.uTexSize.ref.value, width, height));
             ValueCell.update(this.downsampleDepthRenderable.values.uTexSize, Vec2.set(this.downsampleDepthRenderable.values.uTexSize.ref.value, sw, sh));
+            ValueCell.update(this.depthHalfRenderable.values.uTexSize, Vec2.set(this.depthHalfRenderable.values.uTexSize.ref.value, hw, hh));
+            ValueCell.update(this.depthQuarterRenderable.values.uTexSize, Vec2.set(this.depthQuarterRenderable.values.uTexSize.ref.value, qw, qh));
             ValueCell.update(this.ssaoRenderable.values.uTexSize, Vec2.set(this.ssaoRenderable.values.uTexSize.ref.value, sw, sh));
             ValueCell.update(this.ssaoBlurFirstPassRenderable.values.uTexSize, Vec2.set(this.ssaoBlurFirstPassRenderable.values.uTexSize.ref.value, sw, sh));
             ValueCell.update(this.ssaoBlurSecondPassRenderable.values.uTexSize, Vec2.set(this.ssaoBlurSecondPassRenderable.values.uTexSize.ref.value, sw, sh));
@@ -543,11 +624,14 @@ export class PostprocessingPass {
             ValueCell.updateIfChanged(this.ssaoBlurFirstPassRenderable.values.uFar, camera.far);
             ValueCell.updateIfChanged(this.ssaoBlurSecondPassRenderable.values.uFar, camera.far);
 
+            ValueCell.update(this.ssaoBlurFirstPassRenderable.values.uInvProjection, invProjection);
+            ValueCell.update(this.ssaoBlurSecondPassRenderable.values.uInvProjection, invProjection);
+
             if (this.ssaoBlurFirstPassRenderable.values.dOrthographic.ref.value !== orthographic) {
                 needsUpdateSsaoBlur = true;
+                ValueCell.update(this.ssaoBlurFirstPassRenderable.values.dOrthographic, orthographic);
+                ValueCell.update(this.ssaoBlurSecondPassRenderable.values.dOrthographic, orthographic);
             }
-            ValueCell.updateIfChanged(this.ssaoBlurFirstPassRenderable.values.dOrthographic, orthographic);
-            ValueCell.updateIfChanged(this.ssaoBlurSecondPassRenderable.values.dOrthographic, orthographic);
 
             if (this.nSamples !== props.occlusion.params.samples) {
                 needsUpdateSsao = true;
@@ -556,7 +640,30 @@ export class PostprocessingPass {
                 ValueCell.update(this.ssaoRenderable.values.uSamples, getSamples(this.nSamples));
                 ValueCell.updateIfChanged(this.ssaoRenderable.values.dNSamples, this.nSamples);
             }
-            ValueCell.updateIfChanged(this.ssaoRenderable.values.uRadius, Math.pow(2, props.occlusion.params.radius));
+
+            const multiScale = props.occlusion.params.multiScale.name === 'on';
+            if (this.ssaoRenderable.values.dMultiScale.ref.value !== multiScale) {
+                needsUpdateSsao = true;
+                ValueCell.update(this.ssaoRenderable.values.dMultiScale, multiScale);
+            }
+
+            if (props.occlusion.params.multiScale.name === 'on') {
+                const mp = props.occlusion.params.multiScale.params;
+                if (!deepEqual(this.levels, mp.levels)) {
+                    needsUpdateSsao = true;
+
+                    this.levels = mp.levels;
+                    const levels = getLevels(mp.levels);
+                    ValueCell.updateIfChanged(this.ssaoRenderable.values.dLevels, levels.count);
+
+                    ValueCell.update(this.ssaoRenderable.values.uLevelRadius, levels.radius);
+                    ValueCell.update(this.ssaoRenderable.values.uLevelBias, levels.bias);
+                }
+                ValueCell.updateIfChanged(this.ssaoRenderable.values.uNearThreshold, mp.nearThreshold);
+                ValueCell.updateIfChanged(this.ssaoRenderable.values.uFarThreshold, mp.farThreshold);
+            } else {
+                ValueCell.updateIfChanged(this.ssaoRenderable.values.uRadius, Math.pow(2, props.occlusion.params.radius));
+            }
             ValueCell.updateIfChanged(this.ssaoRenderable.values.uBias, props.occlusion.params.bias);
 
             if (this.blurKernelSize !== props.occlusion.params.blurKernelSize) {
@@ -567,8 +674,8 @@ export class PostprocessingPass {
 
                 ValueCell.update(this.ssaoBlurFirstPassRenderable.values.uKernel, kernel);
                 ValueCell.update(this.ssaoBlurSecondPassRenderable.values.uKernel, kernel);
-                ValueCell.updateIfChanged(this.ssaoBlurFirstPassRenderable.values.dOcclusionKernelSize, this.blurKernelSize);
-                ValueCell.updateIfChanged(this.ssaoBlurSecondPassRenderable.values.dOcclusionKernelSize, this.blurKernelSize);
+                ValueCell.update(this.ssaoBlurFirstPassRenderable.values.dOcclusionKernelSize, this.blurKernelSize);
+                ValueCell.update(this.ssaoBlurSecondPassRenderable.values.dOcclusionKernelSize, this.blurKernelSize);
             }
 
             if (this.downsampleFactor !== props.occlusion.params.resolutionScale) {
@@ -579,22 +686,36 @@ export class PostprocessingPass {
 
                 const sw = Math.floor(w * this.ssaoScale);
                 const sh = Math.floor(h * this.ssaoScale);
-
                 this.downsampledDepthTarget.setSize(sw, sh);
                 this.ssaoDepthTexture.define(sw, sh);
                 this.ssaoDepthBlurProxyTexture.define(sw, sh);
 
+                const hw = Math.floor(sw * 0.5);
+                const hh = Math.floor(sh * 0.5);
+                this.depthHalfTarget.setSize(hw, hh);
+
+                const qw = Math.floor(sw * 0.25);
+                const qh = Math.floor(sh * 0.25);
+                this.depthQuarterTarget.setSize(qw, qh);
+
                 if (this.ssaoScale === 1) {
                     ValueCell.update(this.ssaoRenderable.values.tDepth, this.drawPass.depthTextureOpaque);
                 } else {
                     ValueCell.update(this.ssaoRenderable.values.tDepth, this.downsampledDepthTarget.texture);
                 }
 
+                ValueCell.update(this.ssaoRenderable.values.tDepthHalf, this.depthHalfTarget.texture);
+                ValueCell.update(this.ssaoRenderable.values.tDepthQuarter, this.depthQuarterTarget.texture);
+
                 ValueCell.update(this.downsampleDepthRenderable.values.uTexSize, Vec2.set(this.downsampleDepthRenderable.values.uTexSize.ref.value, sw, sh));
+                ValueCell.update(this.depthHalfRenderable.values.uTexSize, Vec2.set(this.depthHalfRenderable.values.uTexSize.ref.value, hw, hh));
+                ValueCell.update(this.depthQuarterRenderable.values.uTexSize, Vec2.set(this.depthQuarterRenderable.values.uTexSize.ref.value, qw, qh));
                 ValueCell.update(this.ssaoRenderable.values.uTexSize, Vec2.set(this.ssaoRenderable.values.uTexSize.ref.value, sw, sh));
                 ValueCell.update(this.ssaoBlurFirstPassRenderable.values.uTexSize, Vec2.set(this.ssaoBlurFirstPassRenderable.values.uTexSize.ref.value, sw, sh));
                 ValueCell.update(this.ssaoBlurSecondPassRenderable.values.uTexSize, Vec2.set(this.ssaoBlurSecondPassRenderable.values.uTexSize.ref.value, sw, sh));
             }
+
+            ValueCell.update(this.renderable.values.uOcclusionColor, Color.toVec3Normalized(this.renderable.values.uOcclusionColor.ref.value, props.occlusion.params.color));
         }
 
         if (props.shadow.name === 'on') {
@@ -611,7 +732,10 @@ export class PostprocessingPass {
 
             ValueCell.updateIfChanged(this.shadowsRenderable.values.uNear, camera.near);
             ValueCell.updateIfChanged(this.shadowsRenderable.values.uFar, camera.far);
-            ValueCell.updateIfChanged(this.shadowsRenderable.values.dOrthographic, orthographic);
+            if (this.shadowsRenderable.values.dOrthographic.ref.value !== orthographic) {
+                ValueCell.update(this.shadowsRenderable.values.dOrthographic, orthographic);
+                needsUpdateShadows = true;
+            }
 
             ValueCell.updateIfChanged(this.shadowsRenderable.values.uMaxDistance, props.shadow.params.maxDistance);
             ValueCell.updateIfChanged(this.shadowsRenderable.values.uTolerance, props.shadow.params.tolerance);
@@ -630,30 +754,33 @@ export class PostprocessingPass {
         }
 
         if (props.outline.name === 'on') {
-            let { threshold, includeTransparent } = props.outline.params;
-            const transparentOutline = includeTransparent ?? true;
-            // orthographic needs lower threshold
-            if (camera.state.mode === 'orthographic') threshold /= 5;
-            const factor = Math.pow(1000, threshold / 10) / 1000;
-            // use radiusMax for stable outlines when zooming
-            const maxPossibleViewZDiff = factor * camera.state.radiusMax;
+            const transparentOutline = props.outline.params.includeTransparent ?? true;
             const outlineScale = props.outline.params.scale - 1;
+            const outlineThreshold = 50 * props.outline.params.threshold;
 
             ValueCell.updateIfChanged(this.outlinesRenderable.values.uNear, camera.near);
             ValueCell.updateIfChanged(this.outlinesRenderable.values.uFar, camera.far);
-            ValueCell.updateIfChanged(this.outlinesRenderable.values.uMaxPossibleViewZDiff, maxPossibleViewZDiff);
-            if (this.renderable.values.dTransparentOutline.ref.value !== transparentOutline) { needsUpdateOutlines = true; }
-            ValueCell.updateIfChanged(this.outlinesRenderable.values.dTransparentOutline, transparentOutline);
+            ValueCell.update(this.outlinesRenderable.values.uInvProjection, invProjection);
+            if (this.outlinesRenderable.values.dTransparentOutline.ref.value !== transparentOutline) {
+                needsUpdateOutlines = true;
+                ValueCell.update(this.outlinesRenderable.values.dTransparentOutline, transparentOutline);
+            }
+            if (this.outlinesRenderable.values.dOrthographic.ref.value !== orthographic) {
+                needsUpdateOutlines = true;
+                ValueCell.update(this.outlinesRenderable.values.dOrthographic, orthographic);
+            }
+            ValueCell.updateIfChanged(this.outlinesRenderable.values.uOutlineThreshold, outlineThreshold);
 
             ValueCell.update(this.renderable.values.uOutlineColor, Color.toVec3Normalized(this.renderable.values.uOutlineColor.ref.value, props.outline.params.color));
 
-            ValueCell.updateIfChanged(this.renderable.values.uMaxPossibleViewZDiff, maxPossibleViewZDiff);
-            ValueCell.update(this.renderable.values.uInvProjection, invProjection);
-
-            if (this.renderable.values.dOutlineScale.ref.value !== outlineScale) { needsUpdateMain = true; }
-            ValueCell.updateIfChanged(this.renderable.values.dOutlineScale, outlineScale);
-            if (this.renderable.values.dTransparentOutline.ref.value !== transparentOutline) { needsUpdateMain = true; }
-            ValueCell.updateIfChanged(this.renderable.values.dTransparentOutline, transparentOutline);
+            if (this.renderable.values.dOutlineScale.ref.value !== outlineScale) {
+                needsUpdateMain = true;
+                ValueCell.update(this.renderable.values.dOutlineScale, outlineScale);
+            }
+            if (this.renderable.values.dTransparentOutline.ref.value !== transparentOutline) {
+                needsUpdateMain = true;
+                ValueCell.update(this.renderable.values.dTransparentOutline, transparentOutline);
+            }
         }
 
         ValueCell.updateIfChanged(this.renderable.values.uFar, camera.far);
@@ -662,15 +789,23 @@ export class PostprocessingPass {
         ValueCell.updateIfChanged(this.renderable.values.uFogNear, camera.fogNear);
         ValueCell.update(this.renderable.values.uFogColor, Color.toVec3Normalized(this.renderable.values.uFogColor.ref.value, backgroundColor));
         ValueCell.updateIfChanged(this.renderable.values.uTransparentBackground, transparentBackground);
-        if (this.renderable.values.dOrthographic.ref.value !== orthographic) { needsUpdateMain = true; }
-        ValueCell.updateIfChanged(this.renderable.values.dOrthographic, orthographic);
+        if (this.renderable.values.dOrthographic.ref.value !== orthographic) {
+            needsUpdateMain = true;
+            ValueCell.update(this.renderable.values.dOrthographic, orthographic);
+        }
 
-        if (this.renderable.values.dOutlineEnable.ref.value !== outlinesEnabled) { needsUpdateMain = true; }
-        ValueCell.updateIfChanged(this.renderable.values.dOutlineEnable, outlinesEnabled);
-        if (this.renderable.values.dShadowEnable.ref.value !== shadowsEnabled) { needsUpdateMain = true; }
-        ValueCell.updateIfChanged(this.renderable.values.dShadowEnable, shadowsEnabled);
-        if (this.renderable.values.dOcclusionEnable.ref.value !== occlusionEnabled) { needsUpdateMain = true; }
-        ValueCell.updateIfChanged(this.renderable.values.dOcclusionEnable, occlusionEnabled);
+        if (this.renderable.values.dOutlineEnable.ref.value !== outlinesEnabled) {
+            needsUpdateMain = true;
+            ValueCell.update(this.renderable.values.dOutlineEnable, outlinesEnabled);
+        }
+        if (this.renderable.values.dShadowEnable.ref.value !== shadowsEnabled) {
+            needsUpdateMain = true;
+            ValueCell.update(this.renderable.values.dShadowEnable, shadowsEnabled);
+        }
+        if (this.renderable.values.dOcclusionEnable.ref.value !== occlusionEnabled) {
+            needsUpdateMain = true;
+            ValueCell.update(this.renderable.values.dOcclusionEnable, occlusionEnabled);
+        }
 
         if (needsUpdateOutlines) {
             this.outlinesRenderable.update();
@@ -699,10 +834,6 @@ export class PostprocessingPass {
         state.disable(gl.BLEND);
         state.disable(gl.DEPTH_TEST);
         state.depthMask(false);
-
-        const { x, y, width, height } = camera.viewport;
-        state.viewport(x, y, width, height);
-        state.scissor(x, y, width, height);
     }
 
     private occlusionOffset: [x: number, y: number] = [0, 0];
@@ -721,25 +852,38 @@ export class PostprocessingPass {
         if (isTimingMode) this.webgl.timer.mark('PostprocessingPass.render');
         this.updateState(camera, transparentBackground, backgroundColor, props, light);
 
-        if (props.outline.name === 'on') {
-            this.outlinesTarget.bind();
-            this.outlinesRenderable.render();
-        }
-
-        if (props.shadow.name === 'on') {
-            this.shadowsTarget.bind();
-            this.shadowsRenderable.render();
-        }
+        const { gl, state } = this.webgl;
+        const { x, y, width, height } = camera.viewport;
 
         // don't render occlusion if offset is given,
         // which will reuse the existing occlusion
         if (props.occlusion.name === 'on' && this.occlusionOffset[0] === 0 && this.occlusionOffset[1] === 0) {
             if (isTimingMode) this.webgl.timer.mark('SSAO.render');
+            const sx = Math.floor(x * this.ssaoScale);
+            const sy = Math.floor(y * this.ssaoScale);
+            const sw = Math.ceil(width * this.ssaoScale);
+            const sh = Math.ceil(height * this.ssaoScale);
+
+            state.viewport(sx, sy, sw, sh);
+            state.scissor(sx, sy, sw, sh);
+
             if (this.ssaoScale < 1) {
+                if (isTimingMode) this.webgl.timer.mark('SSAO.downsample');
                 this.downsampledDepthTarget.bind();
                 this.downsampleDepthRenderable.render();
+                if (isTimingMode) this.webgl.timer.markEnd('SSAO.downsample');
             }
 
+            if (isTimingMode) this.webgl.timer.mark('SSAO.half');
+            this.depthHalfTarget.bind();
+            this.depthHalfRenderable.render();
+            if (isTimingMode) this.webgl.timer.markEnd('SSAO.half');
+
+            if (isTimingMode) this.webgl.timer.mark('SSAO.quarter');
+            this.depthQuarterTarget.bind();
+            this.depthQuarterRenderable.render();
+            if (isTimingMode) this.webgl.timer.markEnd('SSAO.quarter');
+
             this.ssaoFramebuffer.bind();
             this.ssaoRenderable.render();
 
@@ -751,14 +895,25 @@ export class PostprocessingPass {
             if (isTimingMode) this.webgl.timer.markEnd('SSAO.render');
         }
 
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
+
+        if (props.outline.name === 'on') {
+            this.outlinesTarget.bind();
+            this.outlinesRenderable.render();
+        }
+
+        if (props.shadow.name === 'on') {
+            this.shadowsTarget.bind();
+            this.shadowsRenderable.render();
+        }
+
         if (toDrawingBuffer) {
             this.webgl.unbindFramebuffer();
         } else {
             this.target.bind();
         }
 
-        const { gl, state } = this.webgl;
-
         this.background.update(camera, props.background);
         if (this.background.isEnabled(props.background)) {
             if (this.transparentBackground) {
@@ -844,4 +999,3 @@ export class AntialiasingPass {
         }
     }
 }
-

+ 19 - 19
src/mol-gl/_spec/gl.shim.ts

@@ -16,7 +16,7 @@ const c = {
 
     MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84FF,
     MAX_TEXTURE_IMAGE_UNITS_NV: 0x8872
-};
+} as const;
 
 const gl = {
     ACTIVE_ATTRIBUTES: 35721,
@@ -316,7 +316,7 @@ const gl = {
     VERTEX_SHADER: 35633,
     VIEWPORT: 2978,
     ZERO: 0
-};
+} as const;
 type gl = typeof gl
 
 export function createGl(width: number, height: number, contextAttributes: WebGLContextAttributes): WebGLRenderingContext {
@@ -371,66 +371,66 @@ export function createGl(width: number, height: number, contextAttributes: WebGL
                 case 'EXT_blend_minmax': return {
                     MAX_EXT: 0,
                     MIN_EXT: 0
-                } as EXT_blend_minmax;
+                } as unknown as EXT_blend_minmax;
                 case 'EXT_texture_filter_anisotropic': return {
                     MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0,
                     TEXTURE_MAX_ANISOTROPY_EXT: 0
-                } as EXT_texture_filter_anisotropic;
+                } as unknown as EXT_texture_filter_anisotropic;
                 case 'EXT_frag_depth': return {} as EXT_frag_depth;
-                case 'EXT_shader_texture_lod': return {} as EXT_shader_texture_lod;
+                case 'EXT_shader_texture_lod': return {} as unknown as EXT_shader_texture_lod;
                 case 'EXT_sRGB': return {
                     FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING_EXT: 0,
                     SRGB8_ALPHA8_EXT: 0,
                     SRGB_ALPHA_EXT: 0,
                     SRGB_EXT: 0
-                } as EXT_sRGB;
+                } as unknown as EXT_sRGB;
                 case 'OES_vertex_array_object': return {
                     VERTEX_ARRAY_BINDING_OES: 0,
                     bindVertexArrayOES: function (arrayObject: WebGLVertexArrayObjectOES) { },
                     createVertexArrayOES: function (): WebGLVertexArrayObjectOES { return {}; },
                     deleteVertexArrayOES: function (arrayObject: WebGLVertexArrayObjectOES) { },
                     isVertexArrayOES: function (value: any) { return true; }
-                } as OES_vertex_array_object;
+                } as unknown as OES_vertex_array_object;
                 case 'WEBGL_color_buffer_float': return {
                     FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE_EXT: 0,
                     RGB32F_EXT: 0,
                     RGBA32F_EXT: 0,
                     UNSIGNED_NORMALIZED_EXT: 0
-                } as WEBGL_color_buffer_float;
+                } as unknown as WEBGL_color_buffer_float;
                 case 'WEBGL_compressed_texture_astc': return null;
                 case 'WEBGL_compressed_texture_s3tc_srgb': return null;
                 case 'WEBGL_debug_shaders': return {
                     getTranslatedShaderSource(shader: WebGLShader) { return ''; }
-                } as WEBGL_debug_shaders;
+                } as unknown as WEBGL_debug_shaders;
                 case 'WEBGL_draw_buffers': return null;
                 case 'WEBGL_lose_context': return {
                     loseContext: function () { },
                     restoreContext: function () { },
-                } as WEBGL_lose_context;
+                } as unknown as WEBGL_lose_context;
                 case 'WEBGL_depth_texture': return {
                     UNSIGNED_INT_24_8_WEBGL: 0
-                } as WEBGL_depth_texture;
+                } as unknown as WEBGL_depth_texture;
                 case 'WEBGL_debug_renderer_info': return {
                     UNMASKED_RENDERER_WEBGL: 0,
                     UNMASKED_VENDOR_WEBGL: 0
-                } as WEBGL_debug_renderer_info;
+                } as unknown as WEBGL_debug_renderer_info;
                 case 'WEBGL_compressed_texture_s3tc': return null;
-                case 'OES_texture_half_float_linear': return {} as OES_texture_half_float_linear;
+                case 'OES_texture_half_float_linear': return {} as unknown as OES_texture_half_float_linear;
                 case 'OES_texture_half_float': return {
                     HALF_FLOAT_OES: 0
-                } as OES_texture_half_float;
-                case 'OES_texture_float_linear': return {} as OES_texture_float_linear;
-                case 'OES_texture_float': return {} as OES_texture_float;
+                } as unknown as OES_texture_half_float;
+                case 'OES_texture_float_linear': return {} as unknown as OES_texture_float_linear;
+                case 'OES_texture_float': return {} as unknown as OES_texture_float;
                 case 'OES_standard_derivatives': return {
                     FRAGMENT_SHADER_DERIVATIVE_HINT_OES: 0
-                } as OES_standard_derivatives;
-                case 'OES_element_index_uint': return {} as OES_element_index_uint;
+                } as unknown as OES_standard_derivatives;
+                case 'OES_element_index_uint': return {} as unknown as OES_element_index_uint;
                 case 'ANGLE_instanced_arrays': return {
                     drawArraysInstancedANGLE: function (mode: number, first: number, count: number, primcount: number) {},
                     drawElementsInstancedANGLE: function (mode: number, count: number, type: number, offset: number, primcount: number) {},
                     vertexAttribDivisorANGLE: function (index: number, divisor: number) {},
                     VERTEX_ATTRIB_ARRAY_DIVISOR_ANGLE: 0
-                } as ANGLE_instanced_arrays;
+                } as unknown as ANGLE_instanced_arrays;
             }
             return null;
         },

+ 7 - 4
src/mol-gl/renderable/schema.ts

@@ -76,13 +76,15 @@ export function AttributeSpec<K extends AttributeKind>(kind: K, itemSize: Attrib
     return { type: 'attribute', kind, itemSize, divisor };
 }
 
-export type UniformSpec<K extends UniformKind> = { type: 'uniform', kind: K, variant?: 'material' | 'buffered' }
-export function UniformSpec<K extends UniformKind>(kind: K, variant?: 'material' | 'buffered'): UniformSpec<K> {
+type UniformVariant = 'material' | 'buffered'
+export type UniformSpec<K extends UniformKind> = { type: 'uniform', kind: K, variant?: UniformVariant }
+export function UniformSpec<K extends UniformKind>(kind: K, variant?: UniformVariant): UniformSpec<K> {
     return { type: 'uniform', kind, variant };
 }
 
-export type TextureSpec<K extends TextureKind> = { type: 'texture', kind: K, format: TextureFormat, dataType: TextureType, filter: TextureFilter, variant?: 'material' }
-export function TextureSpec<K extends TextureKind>(kind: K, format: TextureFormat, dataType: TextureType, filter: TextureFilter, variant?: 'material'): TextureSpec<K> {
+type TextureVariant = 'material'
+export type TextureSpec<K extends TextureKind> = { type: 'texture', kind: K, format: TextureFormat, dataType: TextureType, filter: TextureFilter, variant?: TextureVariant }
+export function TextureSpec<K extends TextureKind>(kind: K, format: TextureFormat, dataType: TextureType, filter: TextureFilter, variant?: TextureVariant): TextureSpec<K> {
     return { type: 'texture', kind, format, dataType, filter, variant };
 }
 
@@ -160,6 +162,7 @@ export const GlobalUniformSchema = {
     uMarkerAverage: UniformSpec('f'),
 
     uXrayEdgeFalloff: UniformSpec('f'),
+    uExposure: UniformSpec('f'),
 
     uRenderMask: UniformSpec('i'),
     uMarkingDepthTest: UniformSpec('b'),

+ 15 - 7
src/mol-gl/renderer.ts

@@ -104,6 +104,7 @@ export const RendererParams = {
     markerPriority: PD.Select(1, [[1, 'Highlight'], [2, 'Select']]),
 
     xrayEdgeFalloff: PD.Numeric(1, { min: 0.0, max: 3.0, step: 0.1 }),
+    exposure: PD.Numeric(1, { min: 0.0, max: 3.0, step: 0.01 }),
 
     light: PD.ObjectList({
         inclination: PD.Numeric(150, { min: 0, max: 180, step: 1 }),
@@ -130,18 +131,19 @@ export type Light = {
 const tmpDir = Vec3();
 const tmpColor = Vec3();
 function getLight(props: RendererProps['light'], light?: Light): Light {
+    const count = props.length;
     const { direction, color } = light || {
-        direction: (new Array(5 * 3)).fill(0),
-        color: (new Array(5 * 3)).fill(0),
+        direction: (new Array(count * 3)).fill(0),
+        color: (new Array(count * 3)).fill(0),
     };
-    for (let i = 0, il = props.length; i < il; ++i) {
+    for (let i = 0; i < count; ++i) {
         const p = props[i];
         Vec3.directionFromSpherical(tmpDir, degToRad(p.inclination), degToRad(p.azimuth), 1);
         Vec3.toArray(tmpDir, direction, i * 3);
         Vec3.scale(tmpColor, Color.toVec3Normalized(tmpColor, p.color), p.intensity);
         Vec3.toArray(tmpColor, color, i * 3);
     }
-    return { count: props.length, direction, color };
+    return { count, direction, color };
 }
 
 namespace Renderer {
@@ -242,6 +244,7 @@ namespace Renderer {
             uMarkerAverage: ValueCell.create(0),
 
             uXrayEdgeFalloff: ValueCell.create(p.xrayEdgeFalloff),
+            uExposure: ValueCell.create(p.exposure),
         };
         const globalUniformList = Object.entries(globalUniforms);
 
@@ -460,7 +463,8 @@ namespace Renderer {
             for (let i = 0, il = renderables.length; i < il; ++i) {
                 const r = renderables[i];
 
-                if (r.values.markerAverage.ref.value !== 1) {
+                const alpha = clamp(r.values.alpha.ref.value * r.state.alphaFactor, 0, 1);
+                if (alpha !== 0 && r.values.markerAverage.ref.value !== 1) {
                     renderObject(renderables[i], 'marking', Flag.None);
                 }
             }
@@ -607,7 +611,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.dGeometryType.ref.value === 'directVolume' || r.values.dPointStyle?.ref.value === 'fuzzy' || r.values.dGeometryType.ref.value === 'text' || r.values.dXrayShaded?.ref.value) {
+                if ((alpha < 1 && alpha !== 0) || r.values.transparencyAverage.ref.value > 0 || r.values.dGeometryType.ref.value === 'directVolume' || r.values.dPointStyle?.ref.value === 'fuzzy' || r.values.dGeometryType.ref.value === 'text' || r.values.dXrayShaded?.ref.value) {
                     renderObject(r, 'colorWboit', Flag.None);
                 }
             }
@@ -655,7 +659,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.dPointStyle?.ref.value === 'fuzzy' || !!r.values.uBackgroundColor || r.values.dXrayShaded?.ref.value) {
+                if ((alpha < 1 && alpha !== 0) || r.values.transparencyAverage.ref.value > 0 || r.values.dPointStyle?.ref.value === 'fuzzy' || r.values.dGeometryType.ref.value === 'text' || r.values.dXrayShaded?.ref.value) {
                     renderObject(r, 'colorDpoit', Flag.None);
                 }
             }
@@ -787,6 +791,10 @@ namespace Renderer {
                     p.xrayEdgeFalloff = props.xrayEdgeFalloff;
                     ValueCell.update(globalUniforms.uXrayEdgeFalloff, p.xrayEdgeFalloff);
                 }
+                if (props.exposure !== undefined && props.exposure !== p.exposure) {
+                    p.exposure = props.exposure;
+                    ValueCell.update(globalUniforms.uExposure, p.exposure);
+                }
 
                 if (props.light !== undefined && !deepEqual(props.light, p.light)) {
                     p.light = props.light;

+ 1 - 3
src/mol-gl/scene.ts

@@ -8,7 +8,7 @@
 import { WebGLContext } from './webgl/context';
 import { GraphicsRenderObject, createRenderable } from './render-object';
 import { Object3D } from './object3d';
-import { Sphere3D } from '../mol-math/geometry';
+import { Sphere3D } from '../mol-math/geometry/primitives/sphere3d';
 import { CommitQueue } from './commit-queue';
 import { now } from '../mol-util/now';
 import { arraySetRemove } from '../mol-util/array';
@@ -129,10 +129,8 @@ namespace Scene {
                 renderableMap.set(o, renderable);
                 boundingSphereDirty = true;
                 boundingSphereVisibleDirty = true;
-                return renderable;
             } else {
                 console.warn(`RenderObject with id '${o.id}' already present`);
-                return renderableMap.get(o)!;
             }
         }
 

+ 10 - 1
src/mol-gl/shader/chunks/apply-light-color.glsl.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  *
@@ -9,6 +9,13 @@
 
 export const apply_light_color = `
 #ifdef dIgnoreLight
+    #ifdef bumpEnabled
+        if (uBumpFrequency > 0.0 && uBumpAmplitude > 0.0 && bumpiness > 0.0) {
+            material.rgb += fbm(vModelPosition * uBumpFrequency) * (uBumpAmplitude * bumpiness) / uBumpFrequency;
+            material.rgb -= bumpiness / (2.0 * uBumpFrequency);
+        }
+    #endif
+
     gl_FragColor = material;
 #else
     #ifdef bumpEnabled
@@ -65,4 +72,6 @@ export const apply_light_color = `
 #ifdef dXrayShaded
     gl_FragColor.a *= 1.0 - pow(abs(dot(normal, vec3(0.0, 0.0, 1.0))), uXrayEdgeFalloff);
 #endif
+
+gl_FragColor.rgb *= uExposure;
 `;

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

@@ -72,6 +72,7 @@ uniform vec3 uInteriorColor;
 bool interior;
 
 uniform float uXrayEdgeFalloff;
+uniform float uExposure;
 
 uniform mat4 uProjection;
 

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

@@ -75,6 +75,7 @@ uniform vec3 uFogColor;
 uniform float uAlpha;
 uniform bool uTransparentBackground;
 uniform float uXrayEdgeFalloff;
+uniform float uExposure;
 
 uniform int uRenderMask;
 

+ 13 - 4
src/mol-gl/shader/outlines.frag.ts

@@ -16,8 +16,9 @@ uniform vec2 uTexSize;
 
 uniform float uNear;
 uniform float uFar;
+uniform mat4 uInvProjection;
 
-uniform float uMaxPossibleViewZDiff;
+uniform float uOutlineThreshold;
 
 #include common
 
@@ -49,17 +50,25 @@ bool isBackground(const in float depth) {
     return depth == 1.0;
 }
 
+float getPixelSize(const in vec2 coords, const in float depth) {
+    vec3 viewPos0 = screenSpaceToViewSpace(vec3(coords, depth), uInvProjection);
+    vec3 viewPos1 = screenSpaceToViewSpace(vec3(coords + vec2(1.0, 0.0) / uTexSize, depth), uInvProjection);
+    return distance(viewPos0, viewPos1);
+}
+
 void main(void) {
-    float backgroundViewZ = uFar + 3.0 * uMaxPossibleViewZDiff;
+    float backgroundViewZ = 2.0 * uFar;
 
     vec2 coords = gl_FragCoord.xy / uTexSize;
     vec2 invTexSize = 1.0 / uTexSize;
 
     float selfDepthOpaque = getDepthOpaque(coords);
     float selfViewZOpaque = isBackground(selfDepthOpaque) ? backgroundViewZ : getViewZ(selfDepthOpaque);
+    float pixelSizeOpaque = getPixelSize(coords, selfDepthOpaque) * uOutlineThreshold;
 
     float selfDepthTransparent = getDepthTransparent(coords);
     float selfViewZTransparent = isBackground(selfDepthTransparent) ? backgroundViewZ : getViewZ(selfDepthTransparent);
+    float pixelSizeTransparent = getPixelSize(coords, selfDepthTransparent) * uOutlineThreshold;
 
     float outline = 1.0;
     float bestDepth = 1.0;
@@ -73,14 +82,14 @@ void main(void) {
             float sampleDepthTransparent = getDepthTransparent(sampleCoords);
 
             float sampleViewZOpaque = isBackground(sampleDepthOpaque) ? backgroundViewZ : getViewZ(sampleDepthOpaque);
-            if (abs(selfViewZOpaque - sampleViewZOpaque) > uMaxPossibleViewZDiff && selfDepthOpaque > sampleDepthOpaque && sampleDepthOpaque <= bestDepth) {
+            if (abs(selfViewZOpaque - sampleViewZOpaque) > pixelSizeOpaque && selfDepthOpaque > sampleDepthOpaque && sampleDepthOpaque <= bestDepth) {
                 outline = 0.0;
                 bestDepth = sampleDepthOpaque;
             }
 
             if (sampleDepthTransparent < sampleDepthOpaque) {
                 float sampleViewZTransparent = isBackground(sampleDepthTransparent) ? backgroundViewZ : getViewZ(sampleDepthTransparent);
-                if (abs(selfViewZTransparent - sampleViewZTransparent) > uMaxPossibleViewZDiff && selfDepthTransparent > sampleDepthTransparent && sampleDepthTransparent <= bestDepth) {
+                if (abs(selfViewZTransparent - sampleViewZTransparent) > pixelSizeTransparent && selfDepthTransparent > sampleDepthTransparent && sampleDepthTransparent <= bestDepth) {
                     outline = 0.0;
                     bestDepth = sampleDepthTransparent;
                     transparentFlag = 1.0;

+ 5 - 18
src/mol-gl/shader/postprocessing.frag.ts

@@ -24,16 +24,10 @@ uniform float uFogNear;
 uniform float uFogFar;
 uniform vec3 uFogColor;
 uniform vec3 uOutlineColor;
+uniform vec3 uOcclusionColor;
 uniform bool uTransparentBackground;
-
 uniform vec2 uOcclusionOffset;
 
-uniform float uMaxPossibleViewZDiff;
-uniform mat4 uInvProjection;
-
-const float outlineDistanceFactor = 5.0;
-const vec3 occlusionColor = vec3(0.0);
-
 #include common
 
 float getViewZ(const in float depth) {
@@ -64,21 +58,14 @@ bool isBackground(const in float depth) {
     return depth == 1.0;
 }
 
-float getPixelSize(const in vec2 coords, const in float depth) {
-    vec3 viewPos0 = screenSpaceToViewSpace(vec3(coords, depth), uInvProjection);
-    vec3 viewPos1 = screenSpaceToViewSpace(vec3(coords + vec2(1.0, 0.0) / uTexSize, depth), uInvProjection);
-    return distance(viewPos0, viewPos1);
-}
-
 float getOutline(const in vec2 coords, const in float opaqueDepth, out float closestTexel) {
-    float backgroundViewZ = uFar + 3.0 * uMaxPossibleViewZDiff;
+    float backgroundViewZ = 2.0 * uFar;
     vec2 invTexSize = 1.0 / uTexSize;
 
     float transparentDepth = getDepthTransparent(coords);
     float opaqueSelfViewZ = isBackground(opaqueDepth) ? backgroundViewZ : getViewZ(opaqueDepth);
     float transparentSelfViewZ = isBackground(transparentDepth) ? backgroundViewZ : getViewZ(transparentDepth);
     float selfDepth = min(opaqueDepth, transparentDepth);
-    float pixelSize = getPixelSize(coords, selfDepth);
 
     float outline = 1.0;
     closestTexel = 1.0;
@@ -96,7 +83,7 @@ float getOutline(const in vec2 coords, const in float opaqueDepth, out float clo
             float sampleOutlineViewZ = isBackground(sampleOutlineDepth) ? backgroundViewZ : getViewZ(sampleOutlineDepth);
 
             float selfViewZ = sampleOutlineCombined.a == 0.0 ? opaqueSelfViewZ : transparentSelfViewZ;
-            if (sampleOutline == 0.0 && sampleOutlineDepth < closestTexel && abs(selfViewZ - sampleOutlineViewZ) > uMaxPossibleViewZDiff + (pixelSize * outlineDistanceFactor)) {
+            if (sampleOutline == 0.0 && sampleOutlineDepth < closestTexel) {
                 outline = 0.0;
                 closestTexel = sampleOutlineDepth;
             }
@@ -130,9 +117,9 @@ void main(void) {
             fogFactor = smoothstep(uFogNear, uFogFar, viewDist);
             float occlusionFactor = getSsao(coords + uOcclusionOffset);
             if (!uTransparentBackground) {
-                color.rgb = mix(mix(occlusionColor, uFogColor, fogFactor), color.rgb, occlusionFactor);
+                color.rgb = mix(mix(uOcclusionColor, uFogColor, fogFactor), color.rgb, occlusionFactor);
             } else {
-                color.rgb = mix(occlusionColor * (1.0 - fogFactor), color.rgb, occlusionFactor);
+                color.rgb = mix(uOcclusionColor * (1.0 - fogFactor), color.rgb, occlusionFactor);
             }
         }
     #endif

+ 14 - 5
src/mol-gl/shader/ssao-blur.frag.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Áron Samuel Kovács <aron.kovacs@mail.muni.cz>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -19,8 +19,7 @@ uniform float uKernel[dOcclusionKernelSize];
 uniform float uBlurDirectionX;
 uniform float uBlurDirectionY;
 
-uniform float uMaxPossibleViewZDiff;
-
+uniform mat4 uInvProjection;
 uniform float uNear;
 uniform float uFar;
 
@@ -42,6 +41,12 @@ bool outsideBounds(const in vec2 p) {
     return p.x < uBounds.x || p.y < uBounds.y || p.x > uBounds.z || p.y > uBounds.w;
 }
 
+float getPixelSize(const in vec2 coords, const in float depth) {
+    vec3 viewPos0 = screenSpaceToViewSpace(vec3(coords, depth), uInvProjection);
+    vec3 viewPos1 = screenSpaceToViewSpace(vec3(coords + vec2(1.0, 0.0) / uTexSize, depth), uInvProjection);
+    return distance(viewPos0, viewPos1);
+}
+
 void main(void) {
     vec2 coords = gl_FragCoord.xy / uTexSize;
 
@@ -60,6 +65,8 @@ void main(void) {
     }
 
     float selfViewZ = getViewZ(selfDepth);
+    float pixelSize = getPixelSize(coords, selfDepth);
+    float maxDiffViewZ = pixelSize * 10.0;
 
     vec2 offset = vec2(uBlurDirectionX, uBlurDirectionY) / uTexSize;
 
@@ -67,6 +74,8 @@ void main(void) {
     float kernelSum = 0.0;
     // only if kernelSize is odd
     for (int i = -dOcclusionKernelSize / 2; i <= dOcclusionKernelSize / 2; i++) {
+        if (abs(float(i)) > 1.0 && abs(float(i)) * pixelSize > 0.8) continue;
+
         vec2 sampleCoords = coords + float(i) * offset;
         if (outsideBounds(sampleCoords)) {
             continue;
@@ -79,9 +88,9 @@ void main(void) {
             continue;
         }
 
-        if (abs(float(i)) > 1.0) { // abs is not defined for int in webgl1
+        if (abs(float(i)) > 1.0) {
             float sampleViewZ = getViewZ(sampleDepth);
-            if (abs(selfViewZ - sampleViewZ) > uMaxPossibleViewZDiff) {
+            if (abs(selfViewZ - sampleViewZ) > maxDiffViewZ) {
                 continue;
             }
         }

+ 85 - 26
src/mol-gl/shader/ssao.frag.ts

@@ -13,6 +13,8 @@ precision highp sampler2D;
 #include common
 
 uniform sampler2D tDepth;
+uniform sampler2D tDepthHalf;
+uniform sampler2D tDepthQuarter;
 uniform vec2 uTexSize;
 uniform vec4 uBounds;
 
@@ -21,7 +23,14 @@ uniform vec3 uSamples[dNSamples];
 uniform mat4 uProjection;
 uniform mat4 uInvProjection;
 
-uniform float uRadius;
+#ifdef dMultiScale
+    uniform float uLevelRadius[dLevels];
+    uniform float uLevelBias[dLevels];
+    uniform float uNearThreshold;
+    uniform float uFarThreshold;
+#else
+    uniform float uRadius;
+#endif
 uniform float uBias;
 
 float smootherstep(float edge0, float edge1, float x) {
@@ -46,20 +55,38 @@ bool isBackground(const in float depth) {
     return depth == 1.0;
 }
 
-bool outsideBounds(const in vec2 p) {
-    return p.x < uBounds.x || p.y < uBounds.y || p.x > uBounds.z || p.y > uBounds.w;
+float getDepth(const in vec2 coords) {
+    vec2 c = vec2(clamp(coords.x, uBounds.x, uBounds.z), clamp(coords.y, uBounds.y, uBounds.w));
+    #ifdef depthTextureSupport
+        return texture2D(tDepth, c).r;
+    #else
+        return unpackRGBAToDepth(texture2D(tDepth, c));
+    #endif
 }
 
-float getDepth(const in vec2 coords) {
-    if (outsideBounds(coords)) {
-        return 1.0;
-    } else {
-        #ifdef depthTextureSupport
-            return texture2D(tDepth, coords).r;
-        #else
-            return unpackRGBAToDepth(texture2D(tDepth, coords));
-        #endif
-    }
+#define dQuarterThreshold 0.1
+#define dHalfThreshold 0.05
+
+float getMappedDepth(const in vec2 coords, const in vec2 selfCoords) {
+    vec2 c = vec2(clamp(coords.x, uBounds.x, uBounds.z), clamp(coords.y, uBounds.y, uBounds.w));
+    float d = distance(coords, selfCoords);
+    #ifdef depthTextureSupport
+        if (d > dQuarterThreshold) {
+            return texture2D(tDepthQuarter, c).r;
+        } else if (d > dHalfThreshold) {
+            return texture2D(tDepthHalf, c).r;
+        } else {
+            return texture2D(tDepth, c).r;
+        }
+    #else
+        if (d > dQuarterThreshold) {
+            return unpackRGBAToDepth(texture2D(tDepthQuarter, c));
+        } else if (d > dHalfThreshold) {
+            return unpackRGBAToDepth(texture2D(tDepthHalf, c));
+        } else {
+            return unpackRGBAToDepth(texture2D(tDepth, c));
+        }
+    #endif
 }
 
 vec3 normalFromDepth(const in float depth, const in float depth1, const in float depth2, vec2 offset1, vec2 offset2) {
@@ -72,6 +99,12 @@ vec3 normalFromDepth(const in float depth, const in float depth1, const in float
     return normalize(normal);
 }
 
+float getPixelSize(const in vec2 coords, const in float depth) {
+    vec3 viewPos0 = screenSpaceToViewSpace(vec3(coords, depth), uInvProjection);
+    vec3 viewPos1 = screenSpaceToViewSpace(vec3(coords + vec2(1.0, 0.0) / uTexSize, depth), uInvProjection);
+    return distance(viewPos0, viewPos1);
+}
+
 // StarCraft II Ambient Occlusion by [Filion and McNaughton 2008]
 void main(void) {
     vec2 invTexSize = 1.0 / uTexSize;
@@ -95,24 +128,50 @@ void main(void) {
     vec3 selfViewPos = screenSpaceToViewSpace(vec3(selfCoords, selfDepth), uInvProjection);
 
     vec3 randomVec = normalize(vec3(getNoiseVec2(selfCoords) * 2.0 - 1.0, 0.0));
-
     vec3 tangent = normalize(randomVec - selfViewNormal * dot(randomVec, selfViewNormal));
     vec3 bitangent = cross(selfViewNormal, tangent);
     mat3 TBN = mat3(tangent, bitangent, selfViewNormal);
 
     float occlusion = 0.0;
-    for(int i = 0; i < dNSamples; i++){
-        vec3 sampleViewPos = TBN * uSamples[i];
-        sampleViewPos = selfViewPos + sampleViewPos * uRadius;
-
-        vec4 offset = vec4(sampleViewPos, 1.0);
-        offset = uProjection * offset;
-        offset.xyz = (offset.xyz / offset.w) * 0.5 + 0.5;
-
-        float sampleViewZ = screenSpaceToViewSpace(vec3(offset.xy, getDepth(offset.xy)), uInvProjection).z;
-
-        occlusion += step(sampleViewPos.z + 0.025, sampleViewZ) * smootherstep(0.0, 1.0, uRadius / abs(selfViewPos.z - sampleViewZ));
-    }
+    #ifdef dMultiScale
+        float pixelSize = getPixelSize(selfCoords, selfDepth);
+
+        for(int l = 0; l < dLevels; l++) {
+            // TODO: smooth transition
+            if (pixelSize * uNearThreshold > uLevelRadius[l]) continue;
+            if (pixelSize * uFarThreshold < uLevelRadius[l]) continue;
+
+            float levelOcclusion = 0.0;
+            for(int i = 0; i < dNSamples; i++) {
+                vec3 sampleViewPos = TBN * uSamples[i];
+                sampleViewPos = selfViewPos + sampleViewPos * uLevelRadius[l];
+
+                vec4 offset = vec4(sampleViewPos, 1.0);
+                offset = uProjection * offset;
+                offset.xyz = (offset.xyz / offset.w) * 0.5 + 0.5;
+
+                float sampleDepth = getMappedDepth(offset.xy, selfCoords);
+                float sampleViewZ = screenSpaceToViewSpace(vec3(offset.xy, sampleDepth), uInvProjection).z;
+
+                levelOcclusion += step(sampleViewPos.z + 0.025, sampleViewZ) * smootherstep(0.0, 1.0, uLevelRadius[l] / abs(selfViewPos.z - sampleViewZ)) * uLevelBias[l];
+            }
+            occlusion = max(occlusion, levelOcclusion);
+        }
+    #else
+        for(int i = 0; i < dNSamples; i++) {
+            vec3 sampleViewPos = TBN * uSamples[i];
+            sampleViewPos = selfViewPos + sampleViewPos * uRadius;
+
+            vec4 offset = vec4(sampleViewPos, 1.0);
+            offset = uProjection * offset;
+            offset.xyz = (offset.xyz / offset.w) * 0.5 + 0.5;
+
+            float sampleDepth = getMappedDepth(offset.xy, selfCoords);
+            float sampleViewZ = screenSpaceToViewSpace(vec3(offset.xy, sampleDepth), uInvProjection).z;
+
+            occlusion += step(sampleViewPos.z + 0.025, sampleViewZ) * smootherstep(0.0, 1.0, uRadius / abs(selfViewPos.z - sampleViewZ));
+        }
+    #endif
     occlusion = 1.0 - (uBias * occlusion / float(dNSamples));
 
     vec2 packedOcclusion = packUnitIntervalToRG(clamp(occlusion, 0.01, 1.0));

+ 38 - 0
src/mol-gl/webgl/compat.ts

@@ -23,8 +23,28 @@ export function isWebGL2(gl: any): gl is WebGL2RenderingContext {
  * See https://registry.khronos.org/webgl/extensions/ANGLE_instanced_arrays/
  */
 export interface COMPAT_instanced_arrays {
+    /**
+     * Renders primitives from array data like the `drawArrays` method. In addition, it can execute multiple instances of the range of elements.
+     * @param mode the type primitive to render.
+     * @param first the starting index in the array of vector points.
+     * @param count the number of indices to be rendered.
+     * @param primcount the number of instances of the range of elements to execute.
+     */
     drawArraysInstanced(mode: number, first: number, count: number, primcount: number): void;
+    /**
+     * Renders primitives from array data like the `drawElements` method. In addition, it can execute multiple instances of a set of elements.
+     * @param mode the type primitive to render.
+     * @param count the number of elements to be rendered.
+     * @param type the type of the values in the element array buffer.
+     * @param offset an offset in the element array buffer. Must be a valid multiple of the size of the given `type`.
+     * @param primcount the number of instances of the set of elements to execute.
+     */
     drawElementsInstanced(mode: number, count: number, type: number, offset: number, primcount: number): void;
+    /**
+     * Modifies the rate at which generic vertex attributes advance when rendering multiple instances of primitives with `drawArraysInstanced` and `drawElementsInstanced`
+     * @param index the index of the generic vertex attributes.
+     * @param divisor the number of instances that will pass between updates of the generic attribute.
+     */
     vertexAttribDivisor(index: number, divisor: number): void;
     readonly VERTEX_ATTRIB_ARRAY_DIVISOR: number;
 }
@@ -109,6 +129,9 @@ export function getVertexArrayObject(gl: GLRenderingContext): COMPAT_vertex_arra
     }
 }
 
+/**
+ * See https://registry.khronos.org/webgl/extensions/OES_texture_float/
+ */
 export interface COMPAT_texture_float {
 }
 
@@ -116,6 +139,9 @@ export function getTextureFloat(gl: GLRenderingContext): COMPAT_texture_float |
     return isWebGL2(gl) ? {} : gl.getExtension('OES_texture_float');
 }
 
+/**
+ * See https://registry.khronos.org/webgl/extensions/OES_texture_float_linear/
+ */
 export interface COMPAT_texture_float_linear {
 }
 
@@ -123,6 +149,9 @@ export function getTextureFloatLinear(gl: GLRenderingContext): COMPAT_texture_fl
     return gl.getExtension('OES_texture_float_linear');
 }
 
+/**
+ * See https://registry.khronos.org/webgl/extensions/OES_texture_half_float/
+ */
 export interface COMPAT_texture_half_float {
     readonly HALF_FLOAT: number
 }
@@ -137,6 +166,9 @@ export function getTextureHalfFloat(gl: GLRenderingContext): COMPAT_texture_half
     }
 }
 
+/**
+ * See https://registry.khronos.org/webgl/extensions/OES_texture_half_float_linear/
+ */
 export interface COMPAT_texture_half_float_linear {
 }
 
@@ -172,6 +204,9 @@ export function getFragDepth(gl: GLRenderingContext): COMPAT_frag_depth | null {
     return isWebGL2(gl) ? {} : gl.getExtension('EXT_frag_depth');
 }
 
+/**
+ * See https://registry.khronos.org/webgl/extensions/EXT_color_buffer_float/
+ */
 export interface COMPAT_color_buffer_float {
     readonly RGBA32F: number;
 }
@@ -193,6 +228,9 @@ export function getColorBufferFloat(gl: GLRenderingContext): COMPAT_color_buffer
     }
 }
 
+/**
+ * See https://registry.khronos.org/webgl/extensions/EXT_color_buffer_half_float/
+ */
 export interface COMPAT_color_buffer_half_float {
     readonly RGBA16F: number;
 }

+ 3 - 1
src/mol-gl/webgl/program.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -30,6 +30,8 @@ export interface Program {
     destroy: () => void
 }
 
+export type Programs = { [k: string]: Program }
+
 type Locations = { [k: string]: number }
 
 function getLocations(gl: GLRenderingContext, program: WebGLProgram, schema: RenderableSchema) {

+ 10 - 13
src/mol-gl/webgl/render-item.ts

@@ -9,7 +9,7 @@ import { createAttributeBuffers, ElementsBuffer, AttributeKind } from './buffer'
 import { createTextures, Texture } from './texture';
 import { WebGLContext, checkError } from './context';
 import { ShaderCode, DefineValues } from '../shader-code';
-import { Program } from './program';
+import { Program, Programs } from './program';
 import { RenderableSchema, RenderableValues, AttributeSpec, getValueVersions, splitValues, DefineSpec } from '../renderable/schema';
 import { idFactory } from '../../mol-util/id-factory';
 import { ValueCell } from '../../mol-util';
@@ -44,7 +44,7 @@ export interface RenderItem<T extends string> {
     getProgram: (variant: T) => Program
 
     render: (variant: T, sharedTexturesCount: number) => void
-    update: () => Readonly<ValueChanges>
+    update: () => void
     destroy: () => void
 }
 
@@ -71,9 +71,6 @@ function createProgramVariant(ctx: WebGLContext, variant: string, defineValues:
 
 //
 
-type ProgramVariants = Record<string, Program>
-type VertexArrayVariants = Record<string, VertexArray | null>
-
 function createValueChanges() {
     return {
         attributes: false,
@@ -132,7 +129,7 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
 
     const glDrawMode = getDrawMode(ctx, drawMode);
 
-    const programs: ProgramVariants = {};
+    const programs: Programs = {};
     for (const k of renderVariants) {
         programs[k] = createProgramVariant(ctx, k, defineValues, shaderCode, schema);
     }
@@ -147,7 +144,7 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
         elementsBuffer = resources.elements(elements.ref.value);
     }
 
-    const vertexArrays: VertexArrayVariants = {};
+    const vertexArrays: Record<string, VertexArray | null> = {};
     for (const k of renderVariants) {
         vertexArrays[k] = vertexArrayObject ? resources.vertexArray(programs[k], attributeBuffers, elementsBuffer) : null;
     }
@@ -328,7 +325,7 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
                 if (value.ref.version !== versions[k]) {
                     // update of textures with kind 'texture' is done externally
                     if (schema[k].kind !== 'texture') {
-                        // console.log('texture version changed, uploading image', k);
+                        // console.log('materialTexture version changed, uploading image', k);
                         texture.load(value.ref.value as TextureImage<any> | TextureVolume<any>);
                         valueChanges.textures = true;
                     } else {
@@ -346,8 +343,6 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
                     versions[k] = uniform.ref.version;
                 }
             }
-
-            return valueChanges;
         },
         destroy: () => {
             if (!destroyed) {
@@ -358,9 +353,11 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
                 }
                 textures.forEach(([k, texture]) => {
                     // lifetime of textures with kind 'texture' is defined externally
-                    if (schema[k].kind !== 'texture') {
-                        texture.destroy();
-                    }
+                    if (schema[k].kind !== 'texture') texture.destroy();
+                });
+                materialTextures.forEach(([k, texture]) => {
+                    // lifetime of textures with kind 'texture' is defined externally
+                    if (schema[k].kind !== 'texture') texture.destroy();
                 });
                 attributeBuffers.forEach(([_, buffer]) => buffer.destroy());
                 if (elementsBuffer) elementsBuffer.destroy();

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

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -59,7 +59,7 @@ export interface WebGLResources {
     renderbuffer: (format: RenderbufferFormat, attachment: RenderbufferAttachment, width: number, height: number) => Renderbuffer
     shader: (type: ShaderType, source: string) => Shader
     texture: (kind: TextureKind, format: TextureFormat, type: TextureType, filter: TextureFilter) => Texture,
-    cubeTexture: (faces: CubeFaces, mipaps: boolean, onload?: () => void) => Texture,
+    cubeTexture: (faces: CubeFaces, mipmaps: boolean, onload?: () => void) => Texture,
     vertexArray: (program: Program, attributeBuffers: AttributeBuffers, elementsBuffer?: ElementsBuffer) => VertexArray,
 
     getByteCounts: () => ByteCounts

+ 1 - 1
src/mol-gl/webgl/vertex-array.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */

+ 1 - 1
src/mol-io/common/file-handle.ts

@@ -16,7 +16,7 @@ export interface FileHandle {
      * @param position The offset from the beginning of the file from which data should be read.
      * @param sizeOrBuffer The buffer the data will be read from.
      * @param length The number of bytes to read.
-     * @param byteOffset The offset in the buffer at which to start writing.
+     * @param byteOffset The offset in the buffer at which to start reading.
      */
     readBuffer(position: number, sizeOrBuffer: SimpleBuffer | number, length?: number, byteOffset?: number): Promise<{ bytesRead: number, buffer: SimpleBuffer }>
 

+ 2 - 3
src/mol-io/reader/common/text/tokenizer.ts

@@ -134,7 +134,6 @@ namespace Tokenizer {
 
     /** Advance the state by the given number of lines and return line starts/ends as tokens. */
     export async function readLinesAsync(state: Tokenizer, count: number, ctx: RuntimeContext, initialLineCount = 100000): Promise<Tokens> {
-        const { length } = state;
         const lineTokens = TokenBuilder.create(state.data, count * 2);
 
         let linesAlreadyRead = 0;
@@ -143,7 +142,7 @@ namespace Tokenizer {
             readLinesChunk(state, linesToRead, lineTokens);
             linesAlreadyRead += linesToRead;
             return linesToRead;
-        }, (ctx, state) => ctx.update({ message: 'Parsing...', current: state.position, max: length }));
+        }, (ctx, state) => ctx.update({ message: 'Parsing...', current: state.position, max: state.length }));
 
         return lineTokens;
     }
@@ -174,7 +173,7 @@ namespace Tokenizer {
         await chunkedSubtask(ctx, chunkSize, state, (chunkSize, state) => {
             readLinesChunkChecked(state, chunkSize, tokens);
             return state.position < state.length ? chunkSize : 0;
-        }, (ctx, state) => ctx.update({ message: 'Parsing...', current: state.position, max: length }));
+        }, (ctx, state) => ctx.update({ message: 'Parsing...', current: state.position, max: state.length }));
 
         return tokens;
     }

+ 74 - 0
src/mol-math/geometry/_spec/frustum3d.spec.ts

@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Mat4, Vec3 } from '../../linear-algebra';
+import { Box3D } from '../primitives/box3d';
+import { Frustum3D } from '../primitives/frustum3d';
+import { Sphere3D } from '../primitives/sphere3d';
+
+const v3 = Vec3.create;
+const s3 = Sphere3D.create;
+
+describe('frustum3d', () => {
+    it('intersectsSphere3D', () => {
+        const f = Frustum3D();
+        const m = Mat4.perspective(Mat4(), -1, 1, 1, -1, 1, 100);
+        Frustum3D.fromProjectionMatrix(f, m);
+
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, 0), 0))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, 0), 0.9))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, 0), 1.1))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, -50), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, -1.001), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(-1, -1, -1.001), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(-1.1, -1.1, -1.001), 0))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(-1.1, -1.1, -1.001), 0.5))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(1, 1, -1.001), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(1.1, 1.1, -1.001), 0))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(1.1, 1.1, -1.001), 0.5))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, -99.999), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(-99.999, -99.999, -99.999), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(-100.1, -100.1, -100.1), 0))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(-100.1, -100.1, -100.1), 0.5))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(99.999, 99.999, -99.999), 0))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(100.1, 100.1, -100.1), 0))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(100.1, 100.1, -100.1), 0.2))).toBe(true);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, -101), 0))).toBe(false);
+        expect(Frustum3D.intersectsSphere3D(f, s3(v3(0, 0, -101), 1.1))).toBe(true);
+    });
+
+    it('intersectsBox3D', () => {
+        const f = Frustum3D();
+        const m = Mat4.perspective(Mat4(), -1, 1, 1, -1, 1, 100);
+        Frustum3D.fromProjectionMatrix(f, m);
+
+        const b0 = Box3D.create(v3(0, 0, 0), v3(1, 1, 1));
+        expect(Frustum3D.intersectsBox3D(f, b0)). toBe(false);
+
+        const b1 = Box3D.create(v3(-1.1, -1.1, -1.1), v3(-0.1, -0.1, -0.1));
+        expect(Frustum3D.intersectsBox3D(f, b1)). toBe(true);
+    });
+
+    it('containsPoint', () => {
+        const f = Frustum3D();
+        const m = Mat4.perspective(Mat4(), -1, 1, 1, -1, 1, 100);
+        Frustum3D.fromProjectionMatrix(f, m);
+
+        expect(Frustum3D.containsPoint(f, v3(0, 0, 0))).toBe(false);
+        expect(Frustum3D.containsPoint(f, v3(0, 0, -50))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(0, 0, -1.001))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(-1, -1, -1.001))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(-1.1, -1.1, -1.001))).toBe(false);
+        expect(Frustum3D.containsPoint(f, v3(1, 1, -1.001))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(1.1, 1.1, -1.001))).toBe(false);
+        expect(Frustum3D.containsPoint(f, v3(0, 0, -99.999))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(-99.999, -99.999, -99.999))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(-100.1, -100.1, -100.1))).toBe(false);
+        expect(Frustum3D.containsPoint(f, v3(99.999, 99.999, -99.999))).toBe(true);
+        expect(Frustum3D.containsPoint(f, v3(100.1, 100.1, -100.1))).toBe(false);
+        expect(Frustum3D.containsPoint(f, v3(0, 0, -101))).toBe(false);
+    });
+});

+ 40 - 0
src/mol-math/geometry/_spec/plane3d.spec.ts

@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Vec3 } from '../../linear-algebra';
+import { Plane3D } from '../primitives/plane3d';
+
+describe('plane3d', () => {
+    it('fromNormalAndCoplanarPoint', () => {
+        const normal = Vec3.create(1, 1, 1);
+        Vec3.normalize(normal, normal);
+        const p = Plane3D();
+        Plane3D.fromNormalAndCoplanarPoint(p, normal, Vec3.zero());
+
+        expect(p.normal).toEqual(normal);
+        expect(p.constant).toBe(-0);
+    });
+
+    it('fromCoplanarPoints', () => {
+        const a = Vec3.create(2.0, 0.5, 0.25);
+        const b = Vec3.create(2.0, -0.5, 1.25);
+        const c = Vec3.create(2.0, -3.5, 2.2);
+        const p = Plane3D();
+        Plane3D.fromCoplanarPoints(p, a, b, c);
+
+        expect(p.normal).toEqual(Vec3.create(1, 0, 0));
+        expect(p.constant).toBe(-2);
+    });
+
+    it('distanceToPoint', () => {
+        const p = Plane3D.create(Vec3.create(2, 0, 0), -2);
+        Plane3D.normalize(p, p);
+
+        expect(Plane3D.distanceToPoint(p, Vec3.create(0, 0, 0))).toBe(-1);
+        expect(Plane3D.distanceToPoint(p, Vec3.create(4, 0, 0))).toBe(3);
+        expect(Plane3D.distanceToPoint(p, Plane3D.projectPoint(Vec3(), p, Vec3.zero()))).toBe(0);
+    });
+});

+ 21 - 0
src/mol-math/geometry/_spec/polygon.spec.ts

@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Vec2 } from '../../linear-algebra';
+import { pointInPolygon } from '../polygon';
+
+describe('pointInPolygon', () => {
+    it('basic', () => {
+        const polygon = [
+            -1, -1,
+            1, -1,
+            1, 1,
+            -1, 1
+        ];
+        expect(pointInPolygon(Vec2.create(0, 0), polygon, 4)).toBe(true);
+        expect(pointInPolygon(Vec2.create(2, 2), polygon, 4)).toBe(false);
+    });
+});

+ 24 - 0
src/mol-math/geometry/polygon.ts

@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { NumberArray } from '../../mol-util/type-helpers';
+import { Vec2 } from '../linear-algebra';
+
+/** raycast along x-axis and apply even-odd rule */
+export function pointInPolygon(point: Vec2, polygon: NumberArray, count: number): boolean {
+    const [x, y] = point;
+    let inside = false;
+
+    for (let i = 0, j = count - 1; i < count; j = i++) {
+        const xi = polygon[i * 2], yi = polygon[i * 2 + 1];
+        const xj = polygon[j * 2], yj = polygon[j * 2 + 1];
+
+        if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
+            inside = !inside;
+        }
+    }
+    return inside;
+}

+ 41 - 9
src/mol-math/geometry/primitives/box3d.ts

@@ -5,10 +5,11 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Vec3, Mat4 } from '../../linear-algebra';
 import { PositionData } from '../common';
 import { OrderedSet } from '../../../mol-data/int';
 import { Sphere3D } from './sphere3d';
+import { Vec3 } from '../../linear-algebra/3d/vec3';
+import { Mat4 } from '../../linear-algebra/3d/mat4';
 
 interface Box3D { min: Vec3, max: Vec3 }
 
@@ -30,26 +31,48 @@ namespace Box3D {
         return copy(zero(), a);
     }
 
+    const tmpV = Vec3();
+
     /** Get box from sphere, uses extrema if available */
     export function fromSphere3D(out: Box3D, sphere: Sphere3D): Box3D {
         if (Sphere3D.hasExtrema(sphere) && sphere.extrema.length >= 14) { // 14 extrema with coarse boundary helper
             return fromVec3Array(out, sphere.extrema);
         }
-        const r = Vec3.create(sphere.radius, sphere.radius, sphere.radius);
-        Vec3.sub(out.min, sphere.center, r);
-        Vec3.add(out.max, sphere.center, r);
+        Vec3.set(tmpV, sphere.radius, sphere.radius, sphere.radius);
+        Vec3.sub(out.min, sphere.center, tmpV);
+        Vec3.add(out.max, sphere.center, tmpV);
         return out;
     }
 
-    /** Get box from sphere, uses extrema if available */
-    export function fromVec3Array(out: Box3D, array: Vec3[]): Box3D {
-        Box3D.setEmpty(out);
+    export function addVec3Array(out: Box3D, array: Vec3[]): Box3D {
         for (let i = 0, il = array.length; i < il; i++) {
-            Box3D.add(out, array[i]);
+            add(out, array[i]);
         }
         return out;
     }
 
+    export function fromVec3Array(out: Box3D, array: Vec3[]): Box3D {
+        setEmpty(out);
+        addVec3Array(out, array);
+        return out;
+    }
+
+    export function addSphere3D(out: Box3D, sphere: Sphere3D): Box3D {
+        if (Sphere3D.hasExtrema(sphere) && sphere.extrema.length >= 14) { // 14 extrema with coarse boundary helper
+            return addVec3Array(out, sphere.extrema);
+        }
+        add(out, Vec3.subScalar(tmpV, sphere.center, sphere.radius));
+        add(out, Vec3.addScalar(tmpV, sphere.center, sphere.radius));
+        return out;
+    }
+
+    export function intersectsSphere3D(box: Box3D, sphere: Sphere3D) {
+        // Find the point on the AABB closest to the sphere center.
+        Vec3.clamp(tmpV, sphere.center, box.min, box.max);
+        // If that point is inside the sphere, the AABB and sphere intersect.
+        return Vec3.squaredDistance(tmpV, sphere.center) <= (sphere.radius * sphere.radius);
+    }
+
     export function computeBounding(data: PositionData): Box3D {
         const min = Vec3.create(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
         const max = Vec3.create(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE);
@@ -139,7 +162,16 @@ namespace Box3D {
         );
     }
 
-    // const tmpTransformV = Vec3();
+    export function containsSphere3D(box: Box3D, s: Sphere3D) {
+        const c = s.center;
+        const r = s.radius;
+        return (
+            c[0] - r < box.min[0] || c[0] + r > box.max[0] ||
+            c[1] - r < box.min[1] || c[1] + r > box.max[1] ||
+            c[2] - r < box.min[2] || c[2] + r > box.max[2]
+        ) ? false : true;
+    }
+
     export function nearestIntersectionWithRay(out: Vec3, box: Box3D, origin: Vec3, dir: Vec3): Vec3 {
         const [minX, minY, minZ] = box.min;
         const [maxX, maxY, maxZ] = box.max;

+ 99 - 0
src/mol-math/geometry/primitives/frustum3d.ts

@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) 2022-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ *
+ * This code has been modified from https://github.com/mrdoob/three.js/,
+ * copyright (c) 2010-2022 three.js authors. MIT License
+ */
+
+import { Mat4 } from '../../linear-algebra/3d/mat4';
+import { Vec3 } from '../../linear-algebra/3d/vec3';
+import { Box3D } from './box3d';
+import { Plane3D } from './plane3d';
+import { Sphere3D } from './sphere3d';
+
+interface Frustum3D { 0: Plane3D, 1: Plane3D, 2: Plane3D, 3: Plane3D, 4: Plane3D, 5: Plane3D; length: 6; }
+
+function Frustum3D() {
+    return Frustum3D.create(Plane3D(), Plane3D(), Plane3D(), Plane3D(), Plane3D(), Plane3D());
+}
+
+namespace Frustum3D {
+    export const enum PlaneIndex {
+        Right = 0,
+        Left = 1,
+        Bottom = 2,
+        Top = 3,
+        Far = 4,
+        Near = 5,
+    };
+
+    export function create(right: Plane3D, left: Plane3D, bottom: Plane3D, top: Plane3D, far: Plane3D, near: Plane3D): Frustum3D {
+        return [right, left, bottom, top, far, near];
+    }
+
+    export function copy(out: Frustum3D, f: Frustum3D): Frustum3D {
+        for (let i = 0 as PlaneIndex; i < 6; ++i) Plane3D.copy(out[i], f[i]);
+        return out;
+    }
+
+    export function clone(f: Frustum3D): Frustum3D {
+        return copy(Frustum3D(), f);
+    }
+
+    export function fromProjectionMatrix(out: Frustum3D, m: Mat4) {
+        const a00 = m[0], a01 = m[1], a02 = m[2], a03 = m[3];
+        const a10 = m[4], a11 = m[5], a12 = m[6], a13 = m[7];
+        const a20 = m[8], a21 = m[9], a22 = m[10], a23 = m[11];
+        const a30 = m[12], a31 = m[13], a32 = m[14], a33 = m[15];
+
+        Plane3D.setUnnormalized(out[0], a03 - a00, a13 - a10, a23 - a20, a33 - a30);
+        Plane3D.setUnnormalized(out[1], a03 + a00, a13 + a10, a23 + a20, a33 + a30);
+        Plane3D.setUnnormalized(out[2], a03 + a01, a13 + a11, a23 + a21, a33 + a31);
+        Plane3D.setUnnormalized(out[3], a03 - a01, a13 - a11, a23 - a21, a33 - a31);
+        Plane3D.setUnnormalized(out[4], a03 - a02, a13 - a12, a23 - a22, a33 - a32);
+        Plane3D.setUnnormalized(out[5], a03 + a02, a13 + a12, a23 + a22, a33 + a32);
+
+        return out;
+    }
+
+    export function intersectsSphere3D(frustum: Frustum3D, sphere: Sphere3D) {
+        const center = sphere.center;
+        const negRadius = -sphere.radius;
+
+        for (let i = 0 as PlaneIndex; i < 6; ++i) {
+            const distance = Plane3D.distanceToPoint(frustum[i], center);
+            if (distance < negRadius) return false;
+        }
+        return true;
+    }
+
+    const boxTmpV = Vec3();
+    export function intersectsBox3D(frustum: Frustum3D, box: Box3D) {
+        for (let i = 0 as PlaneIndex; i < 6; ++i) {
+            const plane = frustum[i];
+
+            // corner at max distance
+            boxTmpV[0] = plane.normal[0] > 0 ? box.max[0] : box.min[0];
+            boxTmpV[1] = plane.normal[1] > 0 ? box.max[1] : box.min[1];
+            boxTmpV[2] = plane.normal[2] > 0 ? box.max[2] : box.min[2];
+
+            if (Plane3D.distanceToPoint(plane, boxTmpV) < 0) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    export function containsPoint(frustum: Frustum3D, point: Vec3) {
+        for (let i = 0 as PlaneIndex; i < 6; ++i) {
+            if (Plane3D.distanceToPoint(frustum[i], point) < 0) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
+
+export { Frustum3D };

+ 93 - 0
src/mol-math/geometry/primitives/plane3d.ts

@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2022-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ *
+ * This code has been modified from https://github.com/mrdoob/three.js/,
+ * copyright (c) 2010-2022 three.js authors. MIT License
+ */
+
+import { NumberArray } from '../../../mol-util/type-helpers';
+import { Vec3 } from '../../linear-algebra/3d/vec3';
+import { Sphere3D } from './sphere3d';
+
+interface Plane3D { normal: Vec3, constant: number }
+
+function Plane3D() {
+    return Plane3D.create(Vec3.create(1, 0, 0), 0);
+}
+
+namespace Plane3D {
+    export function create(normal: Vec3, constant: number): Plane3D { return { normal, constant }; }
+
+    export function copy(out: Plane3D, p: Plane3D): Plane3D {
+        Vec3.copy(out.normal, p.normal);
+        out.constant = p.constant;
+        return out;
+    }
+
+    export function clone(p: Plane3D): Plane3D {
+        return copy(Plane3D(), p);
+    }
+
+    export function normalize(out: Plane3D, p: Plane3D): Plane3D {
+        // Note: will lead to a divide by zero if the plane is invalid.
+        const inverseNormalLength = 1.0 / Vec3.magnitude(p.normal);
+        Vec3.scale(out.normal, p.normal, inverseNormalLength);
+        out.constant = p.constant * inverseNormalLength;
+        return out;
+    }
+
+    export function negate(out: Plane3D, p: Plane3D): Plane3D {
+        Vec3.negate(out.normal, p.normal);
+        out.constant = -p.constant;
+        return out;
+    }
+
+    export function toArray<T extends NumberArray>(p: Plane3D, out: T, offset: number) {
+        Vec3.toArray(p.normal, out, offset);
+        out[offset + 3] = p.constant;
+        return out;
+    }
+
+    export function fromArray(out: Plane3D, array: NumberArray, offset: number) {
+        Vec3.fromArray(out.normal, array, offset);
+        out.constant = array[offset + 3];
+        return out;
+    }
+
+    export function fromNormalAndCoplanarPoint(out: Plane3D, normal: Vec3, point: Vec3) {
+        Vec3.copy(out.normal, normal);
+        out.constant = -Vec3.dot(out.normal, point);
+        return out;
+    }
+
+    export function fromCoplanarPoints(out: Plane3D, a: Vec3, b: Vec3, c: Vec3) {
+        const normal = Vec3.triangleNormal(Vec3(), a, b, c);
+        fromNormalAndCoplanarPoint(out, normal, a);
+        return out;
+    }
+
+    const unnormTmpV = Vec3();
+    export function setUnnormalized(out: Plane3D, nx: number, ny: number, nz: number, constant: number) {
+        Vec3.set(unnormTmpV, nx, ny, nz);
+        const inverseNormalLength = 1.0 / Vec3.magnitude(unnormTmpV);
+        Vec3.scale(out.normal, unnormTmpV, inverseNormalLength);
+        out.constant = constant * inverseNormalLength;
+        return out;
+    }
+
+    export function distanceToPoint(plane: Plane3D, point: Vec3) {
+        return Vec3.dot(plane.normal, point) + plane.constant;
+    }
+
+    export function distanceToSpher3D(plane: Plane3D, sphere: Sphere3D) {
+        return distanceToPoint(plane, sphere.center) - sphere.radius;
+    }
+
+    export function projectPoint(out: Vec3, plane: Plane3D, point: Vec3) {
+        return Vec3.scaleAndAdd(out, out, plane.normal, -distanceToPoint(plane, point));
+    }
+}
+
+export { Plane3D };

+ 2 - 1
src/mol-math/geometry/primitives/sphere3d.ts

@@ -109,9 +109,10 @@ namespace Sphere3D {
         return out;
     }
 
-    export function toArray(s: Sphere3D, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(s: Sphere3D, out: T, offset: number) {
         Vec3.toArray(s.center, out, offset);
         out[offset + 3] = s.radius;
+        return out;
     }
 
     export function fromArray(out: Sphere3D, array: NumberArray, offset: number) {

+ 9 - 1
src/mol-math/linear-algebra/3d/mat3.ts

@@ -64,7 +64,7 @@ namespace Mat3 {
         return mat;
     }
 
-    export function toArray(a: Mat3, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(a: Mat3, out: T, offset: number) {
         out[offset + 0] = a[0];
         out[offset + 1] = a[1];
         out[offset + 2] = a[2];
@@ -454,6 +454,14 @@ namespace Mat3 {
     }
 
     export const Identity: ReadonlyMat3 = identity();
+
+    /** Return the Frobenius inner product of two matrices (= dot product of the flattened matrices).
+     * Can be used as a measure of similarity between two rotation matrices. */
+    export function innerProduct(a: Mat3, b: Mat3) {
+        return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+            + a[3] * b[3] + a[4] * b[4] + a[5] * b[5]
+            + a[6] * b[6] + a[7] * b[7] + a[8] * b[8];
+    }
 }
 
 export { Mat3 };

+ 1 - 1
src/mol-math/linear-algebra/3d/mat4.ts

@@ -124,7 +124,7 @@ namespace Mat4 {
         return a[4 * j + i];
     }
 
-    export function toArray(a: Mat4, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(a: Mat4, out: T, offset: number) {
         out[offset + 0] = a[0];
         out[offset + 1] = a[1];
         out[offset + 2] = a[2];

+ 1 - 1
src/mol-math/linear-algebra/3d/quat.ts

@@ -314,7 +314,7 @@ namespace Quat {
         return out;
     }
 
-    export function toArray(a: Quat, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(a: Quat, out: T, offset: number) {
         out[offset + 0] = a[0];
         out[offset + 1] = a[1];
         out[offset + 2] = a[2];

+ 1 - 1
src/mol-math/linear-algebra/3d/vec2.ts

@@ -50,7 +50,7 @@ namespace Vec2 {
         return isNaN(a[0]) || isNaN(a[1]);
     }
 
-    export function toArray(a: Vec2, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(a: Vec2, out: T, offset: number) {
         out[offset + 0] = a[0];
         out[offset + 1] = a[1];
         return out;

+ 23 - 5
src/mol-math/linear-algebra/3d/vec3.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2022 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>
@@ -18,7 +18,7 @@
  */
 
 import { Mat4 } from './mat4';
-import { spline as _spline, quadraticBezier as _quadraticBezier, clamp } from '../../interpolate';
+import { spline as _spline, quadraticBezier as _quadraticBezier, clamp as _clamp } from '../../interpolate';
 import { NumberArray } from '../../../mol-util/type-helpers';
 import { Mat3 } from './mat3';
 import { Quat } from './quat';
@@ -74,7 +74,7 @@ namespace Vec3 {
         return v;
     }
 
-    export function toArray(v: Vec3, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(v: Vec3, out: T, offset: number) {
         out[offset + 0] = v[0];
         out[offset + 1] = v[1];
         out[offset + 2] = v[2];
@@ -246,6 +246,16 @@ namespace Vec3 {
         return out;
     }
 
+    /**
+     * Assumes min < max, componentwise
+     */
+    export function clamp(out: Vec3, a: Vec3, min: Vec3, max: Vec3) {
+        out[0] = Math.max(min[0], Math.min(max[0], a[0]));
+        out[1] = Math.max(min[1], Math.min(max[1], a[1]));
+        out[2] = Math.max(min[2], Math.min(max[2], a[2]));
+        return out;
+    }
+
     export function distance(a: Vec3, b: Vec3) {
         const x = b[0] - a[0],
             y = b[1] - a[1],
@@ -341,7 +351,7 @@ namespace Vec3 {
 
     const slerpRelVec = zero();
     export function slerp(out: Vec3, a: Vec3, b: Vec3, t: number) {
-        const d = clamp(dot(a, b), -1, 1);
+        const d = _clamp(dot(a, b), -1, 1);
         const theta = Math.acos(d) * t;
         scaleAndAdd(slerpRelVec, b, a, -d);
         normalize(slerpRelVec, slerpRelVec);
@@ -429,6 +439,14 @@ namespace Vec3 {
         return out;
     }
 
+    export function transformDirection(out: Vec3, a: Vec3, m: Mat4) {
+        const x = a[0], y = a[1], z = a[2];
+        out[0] = m[0] * x + m[4] * y + m[8] * z;
+        out[1] = m[1] * x + m[5] * y + m[9] * z;
+        out[2] = m[2] * x + m[6] * y + m[10] * z;
+        return normalize(out, out);
+    }
+
     /**
      * Like `transformMat4` but with offsets into arrays
      */
@@ -477,7 +495,7 @@ namespace Vec3 {
         const denominator = Math.sqrt(squaredMagnitude(a) * squaredMagnitude(b));
         if (denominator === 0) return Math.PI / 2;
         const theta = dot(a, b) / denominator;
-        return Math.acos(clamp(theta, -1, 1)); // clamp to avoid numerical problems
+        return Math.acos(_clamp(theta, -1, 1)); // clamp to avoid numerical problems
     }
 
     const tmp_dh_ab = zero();

+ 1 - 1
src/mol-math/linear-algebra/3d/vec4.ts

@@ -70,7 +70,7 @@ namespace Vec4 {
         return isNaN(a[0]) || isNaN(a[1]) || isNaN(a[2]) || isNaN(a[3]);
     }
 
-    export function toArray(a: Vec4, out: NumberArray, offset: number) {
+    export function toArray<T extends NumberArray>(a: Vec4, out: T, offset: number) {
         out[offset + 0] = a[0];
         out[offset + 1] = a[1];
         out[offset + 2] = a[2];

+ 1 - 1
src/mol-model-props/computed/interactions/contacts.ts

@@ -44,7 +44,7 @@ function validPair(structure: Structure, infoA: Features.Info, infoB: Features.I
     const altA = altLoc(infoA.unit, indexA);
     const altB = altLoc(infoB.unit, indexB);
     if (altA && altB && altA !== altB) return false; // incompatible alternate location id
-    if (infoA.unit.residueIndex[infoA.unit.elements[indexA]] === infoB.unit.residueIndex[infoB.unit.elements[indexB]] && infoA.unit === infoB.unit) return false; // same residue
+    if (infoA.unit === infoB.unit && infoA.unit.model.atomicHierarchy.residueAtomSegments.count > 1 && infoA.unit.residueIndex[infoA.unit.elements[indexA]] === infoB.unit.residueIndex[infoB.unit.elements[indexB]]) return false; // same residue (and more than one residue)
 
     // e.g. no hbond if donor and acceptor are bonded
     if (connectedTo(structure, infoA.unit, indexA, infoB.unit, indexB)) return false;

+ 5 - 5
src/mol-model/structure/coordinates/coordinates.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 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>
@@ -99,7 +99,7 @@ namespace Coordinates {
     /**
      * Only use ordering if it's not identity.
      */
-    export function getAtomicConformation(frame: Frame, atomId: Column<number>, ordering?: ArrayLike<number>): AtomicConformation {
+    export function getAtomicConformation(frame: Frame, fields: { atomId: Column<number>, occupancy?: Column<number>, B_iso_or_equiv?: Column<number> }, ordering?: ArrayLike<number>): AtomicConformation {
         let { x, y, z } = frame;
 
         if (frame.xyzOrdering.frozen) {
@@ -143,9 +143,9 @@ namespace Coordinates {
 
         return {
             id: UUID.create22(),
-            atomId,
-            occupancy: Column.ofConst(1, frame.elementCount, Column.Schema.int),
-            B_iso_or_equiv: Column.ofConst(0, frame.elementCount, Column.Schema.float),
+            atomId: fields.atomId,
+            occupancy: fields.occupancy ?? Column.ofConst(1, frame.elementCount, Column.Schema.int),
+            B_iso_or_equiv: fields.B_iso_or_equiv ?? Column.ofConst(0, frame.elementCount, Column.Schema.float),
             xyzDefined: true,
             x,
             y,

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

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2023 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>
@@ -112,7 +112,11 @@ export namespace Model {
                 ...model,
                 id: UUID.create22(),
                 modelNum: i,
-                atomicConformation: Coordinates.getAtomicConformation(f, model.atomicConformation.atomId, srcIndexArray),
+                atomicConformation: Coordinates.getAtomicConformation(f, {
+                    atomId: model.atomicConformation.atomId,
+                    occupancy: model.atomicConformation.occupancy,
+                    B_iso_or_equiv: model.atomicConformation.B_iso_or_equiv
+                }, srcIndexArray),
                 // TODO: add support for supplying sphere and gaussian coordinates in addition to atomic coordinates?
                 // coarseConformation: coarse.conformation,
                 customProperties: new CustomProperties(),

+ 5 - 1
src/mol-model/structure/structure/element/location.ts

@@ -55,13 +55,17 @@ namespace Location {
         return a.unit === b.unit && a.element === b.element;
     }
 
-    const pA = Vec3.zero(), pB = Vec3.zero();
+    const pA = Vec3(), pB = Vec3();
     export function distance(a: Location, b: Location) {
         a.unit.conformation.position(a.element, pA);
         b.unit.conformation.position(b.element, pB);
         return Vec3.distance(pA, pB);
     }
 
+    export function position(out: Vec3, l: Location): Vec3 {
+        return l.unit.conformation.position(l.element, out);
+    }
+
     export function residueIndex(l: Location) {
         return l.unit.model.atomicHierarchy.residueAtomSegments.index[l.element];
     }

+ 1 - 1
src/mol-model/structure/structure/element/loci.ts

@@ -148,7 +148,7 @@ export namespace Loci {
      * The loc argument of the callback is mutable, use Location.clone() if you intend to keep
      * the value around.
      */
-    export function forEachLocation(loci: Loci, f: (loc: Location) => any) {
+    export function forEachLocation(loci: Loci, f: (loc: Location) => void) {
         if (Loci.isEmpty(loci)) return;
 
         const location = Location.create(loci.structure);

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

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2023 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>
@@ -1225,8 +1225,10 @@ namespace Structure {
             const closeUnits = lookup.findUnitIndices(imageCenter[0], imageCenter[1], imageCenter[2], bs.radius + maxRadius);
             for (let i = 0; i < closeUnits.count; i++) {
                 const other = structure.units[closeUnits.indices[i]];
+                if (unit.id >= other.id) continue;
+
                 if (other.elements.length > 3 && !Box3D.overlaps(bbox, other.boundary.box)) continue;
-                if (!validUnit(other) || unit.id >= other.id || !validUnitPair(unit, other)) continue;
+                if (!validUnit(other) || !validUnitPair(unit, other)) continue;
 
                 if (other.elements.length >= unit.elements.length) callback(unit, other);
                 else callback(other, unit);

+ 27 - 13
src/mol-model/structure/structure/unit/bonds/inter-compute.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2022 Mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2023 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>
@@ -243,6 +243,11 @@ function computeInterUnitBonds(structure: Structure, props?: Partial<InterBondCo
         ...p,
         validUnit: (props && props.validUnit) || (u => Unit.isAtomic(u)),
         validUnitPair: (props && props.validUnitPair) || ((s, a, b) => {
+            // In case both units have a struct conn record, ignore other criteria
+            if (hasCommonStructConnRecord(a, b)) {
+                return Structure.validUnitPair(s, a, b);
+            }
+
             const mtA = a.model.atomicHierarchy.derived.residue.moleculeType;
             const mtB = b.model.atomicHierarchy.derived.residue.moleculeType;
             const notWater = (
@@ -250,25 +255,34 @@ function computeInterUnitBonds(structure: Structure, props?: Partial<InterBondCo
                 (!Unit.isAtomic(b) || mtB[b.residueIndex[b.elements[0]]] !== MoleculeType.Water)
             );
 
-            const sameModel = a.model === b.model;
-            const notIonA = (!Unit.isAtomic(a) || mtA[a.residueIndex[a.elements[0]]] !== MoleculeType.Ion) || (sameModel && hasStructConnRecord(a));
-            const notIonB = (!Unit.isAtomic(b) || mtB[b.residueIndex[b.elements[0]]] !== MoleculeType.Ion) || (sameModel && hasStructConnRecord(b));
+            const notIonA = (!Unit.isAtomic(a) || mtA[a.residueIndex[a.elements[0]]] !== MoleculeType.Ion);
+            const notIonB = (!Unit.isAtomic(b) || mtB[b.residueIndex[b.elements[0]]] !== MoleculeType.Ion);
             const notIon = notIonA && notIonB;
             return Structure.validUnitPair(s, a, b) && (notWater || !p.ignoreWater) && (notIon || !p.ignoreIon);
         }),
     });
 }
 
-function hasStructConnRecord(unit: Unit) {
-    if (!Unit.isAtomic(unit)) return false;
+function hasCommonStructConnRecord(unitA: Unit, unitB: Unit) {
+    if (unitA.model !== unitB.model || !Unit.isAtomic(unitA) || !Unit.isAtomic(unitB)) return false;
+    const structConn = StructConn.Provider.get(unitA.model);
+    if (!structConn) return false;
 
-    const elements = unit.elements;
-    const structConn = StructConn.Provider.get(unit.model);
-    if (structConn) {
-        for (let i = 0, _i = elements.length; i < _i; i++) {
-            if (structConn.byAtomIndex.get(elements[i])) {
-                return true;
-            }
+    const smaller = unitA.elements.length < unitB.elements.length ? unitA : unitB;
+    const bigger = unitA.elements.length >= unitB.elements.length ? unitA : unitB;
+
+    const { elements: xs } = smaller;
+    const { elements: ys } = bigger;
+    const { indexOf } = SortedArray;
+
+    for (let i = 0, _i = xs.length; i < _i; i++) {
+        const aI = xs[i];
+        const entries = structConn.byAtomIndex.get(aI);
+        if (!entries?.length) continue;
+
+        for (const e of entries) {
+            const bI = e.partnerA.atomIndex === aI ? e.partnerB.atomIndex : e.partnerA.atomIndex;
+            if (indexOf(ys, bI) >= 0) return true;
         }
     }
     return false;

+ 4 - 4
src/mol-plugin-state/actions/file.ts

@@ -8,13 +8,13 @@ import { PluginContext } from '../../mol-plugin/context';
 import { StateAction } from '../../mol-state';
 import { Task } from '../../mol-task';
 import { Asset } from '../../mol-util/assets';
-import { getFileInfo } from '../../mol-util/file-info';
+import { getFileNameInfo } from '../../mol-util/file-info';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { unzip } from '../../mol-util/zip/zip';
 import { PluginStateObject } from '../objects';
 
 async function processFile(file: Asset.File, plugin: PluginContext, format: string, visuals: boolean) {
-    const info = getFileInfo(file.file!);
+    const info = getFileNameInfo(file.file?.name ?? '');
     const isBinary = plugin.dataFormats.binaryExtensions.has(info.ext);
     const { data } = await plugin.builders.data.readFile({ file, isBinary });
     const provider = format === 'auto'
@@ -111,8 +111,8 @@ export const DownloadFile = StateAction.build({
                     }
                 } else {
                     const url = Asset.getUrl(params.url);
-                    const info = getFileInfo(url);
-                    await processFile(Asset.File(new File([data.obj?.data as Uint8Array], info.name)), plugin, 'auto', params.visuals);
+                    const fileName = getFileNameInfo(url).name;
+                    await processFile(Asset.File(new File([data.obj?.data as Uint8Array], fileName)), plugin, 'auto', params.visuals);
                 }
             } else {
                 const provider = plugin.dataFormats.get(params.format);

+ 3 - 3
src/mol-plugin-state/actions/structure.ts

@@ -18,7 +18,7 @@ import { Download } from '../transforms/data';
 import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, TrajectoryFromModelAndCoordinates } from '../transforms/model';
 import { Asset } from '../../mol-util/assets';
 import { PluginConfig } from '../../mol-plugin/config';
-import { getFileInfo } from '../../mol-util/file-info';
+import { getFileNameInfo } from '../../mol-util/file-info';
 import { assertUnreachable } from '../../mol-util/type-helpers';
 import { TopologyFormatCategory } from '../formats/topology';
 import { CoordinatesFormatCategory } from '../formats/coordinates';
@@ -184,7 +184,7 @@ const DownloadStructure = StateAction.build({
             for (const download of downloadParams) {
                 const data = await plugin.builders.data.download(download, { state: { isGhost: true } });
                 const provider = format === 'auto'
-                    ? plugin.dataFormats.auto(getFileInfo(Asset.getUrl(download.url)), data.cell?.obj!)
+                    ? plugin.dataFormats.auto(getFileNameInfo(Asset.getUrl(download.url)), data.cell?.obj!)
                     : plugin.dataFormats.get(format);
                 if (!provider) throw new Error('unknown file format');
                 const trajectory = await plugin.builders.structure.parseTrajectory(data, provider);
@@ -385,7 +385,7 @@ export const LoadTrajectory = StateAction.build({
         const processFile = async (file: Asset.File | null) => {
             if (!file) throw new Error('No file selected');
 
-            const info = getFileInfo(file.file!);
+            const info = getFileNameInfo(file.file?.name ?? '');
             const isBinary = ctx.dataFormats.binaryExtensions.has(info.ext);
             const { data } = await ctx.builders.data.readFile({ file, isBinary });
             const provider = ctx.dataFormats.auto(info, data.cell?.obj!);

+ 2 - 2
src/mol-plugin-state/actions/volume.ts

@@ -8,7 +8,7 @@
 import { PluginContext } from '../../mol-plugin/context';
 import { StateAction, StateTransformer, StateSelection } from '../../mol-state';
 import { Task } from '../../mol-task';
-import { getFileInfo } from '../../mol-util/file-info';
+import { getFileNameInfo } from '../../mol-util/file-info';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { PluginStateObject } from '../objects';
 import { Download } from '../transforms/data';
@@ -119,7 +119,7 @@ const DownloadDensity = StateAction.build({
     switch (src.name) {
         case 'url':
             downloadParams = src.params;
-            provider = src.params.format === 'auto' ? plugin.dataFormats.auto(getFileInfo(Asset.getUrl(downloadParams.url)), data.cell?.obj!) : plugin.dataFormats.get(src.params.format);
+            provider = src.params.format === 'auto' ? plugin.dataFormats.auto(getFileNameInfo(Asset.getUrl(downloadParams.url)), data.cell?.obj!) : plugin.dataFormats.get(src.params.format);
             break;
         case 'pdb-xray':
             entryId = src.params.provider.id;

+ 2 - 2
src/mol-plugin-state/builder/data.ts

@@ -7,7 +7,7 @@
 import { StateTransformer, StateTransform } from '../../mol-state';
 import { PluginContext } from '../../mol-plugin/context';
 import { Download, ReadFile, DownloadBlob, RawData } from '../transforms/data';
-import { getFileInfo } from '../../mol-util/file-info';
+import { getFileNameInfo } from '../../mol-util/file-info';
 
 export class DataBuilder {
     private get dataState() {
@@ -31,7 +31,7 @@ export class DataBuilder {
 
     async readFile(params: StateTransformer.Params<ReadFile>, options?: Partial<StateTransform.Options>) {
         const data = await this.dataState.build().toRoot().apply(ReadFile, params, options).commit({ revertOnError: true });
-        const fileInfo = getFileInfo(params.file?.file || '');
+        const fileInfo = getFileNameInfo(params.file?.file?.name ?? '');
         return { data: data, fileInfo };
     }
 

+ 3 - 3
src/mol-plugin-state/builder/structure/hierarchy-preset.ts

@@ -170,9 +170,9 @@ const ccd = TrajectoryHierarchyPresetProvider({
             plugin.log.info(`Superposed [model] and [ideal] with RMSD ${rmsd.toFixed(2)}.`);
         }
 
-        const representationPreset = params.representationPreset || plugin.config.get(PluginConfig.Structure.DefaultRepresentationPreset) || PresetStructureRepresentations.auto.id;
-        await builder.representation.applyPreset(idealStructureProperties, representationPreset, params.representationPresetParams);
-        await builder.representation.applyPreset(modelStructureProperties, representationPreset, params.representationPresetParams);
+        const representationPreset = params.representationPreset || PresetStructureRepresentations['chemical-component'].id;
+        await builder.representation.applyPreset(idealStructureProperties, representationPreset, { ...params.representationPresetParams, coordinateType: 'Ideal' });
+        await builder.representation.applyPreset(modelStructureProperties, representationPreset, { ...params.representationPresetParams, coordinateType: 'Model' });
 
         return { models: [idealModel, modelModel], structures: [idealStructure, modelStructure] };
     }

+ 38 - 0
src/mol-plugin-state/builder/structure/representation-preset.ts

@@ -24,6 +24,7 @@ import { IndexPairBonds } from '../../../mol-model-formats/structure/property/bo
 import { StructConn } from '../../../mol-model-formats/structure/property/bonds/struct_conn';
 import { StructureRepresentationRegistry } from '../../../mol-repr/structure/registry';
 import { assertUnreachable } from '../../../mol-util/type-helpers';
+import { CCDFormat } from '../../../mol-model-formats/structure/mmcif';
 
 export interface StructureRepresentationPresetProvider<P = any, S extends _Result = _Result> extends PresetProvider<PluginStateObject.Molecule.Structure, P, S> { }
 export function StructureRepresentationPresetProvider<P, S extends _Result>(repr: StructureRepresentationPresetProvider<P, S>) { return repr; }
@@ -429,6 +430,42 @@ const illustrative = StructureRepresentationPresetProvider({
     }
 });
 
+const chemicalComponent = StructureRepresentationPresetProvider({
+    id: 'preset-structure-representation-chemical-component',
+    display: {
+        name: 'Chemical Component', group: 'Miscellaneous',
+        description: `Show 'ideal' and 'model' coordinates of chemical components.`
+    },
+    isApplicable: o => {
+        return CCDFormat.is(o.data.model.sourceData);
+    },
+    params: () => ({
+        ...CommonParams,
+        coordinateType: PD.Select('Ideal', PD.arrayToOptions(['Ideal', 'Model'] as const))
+    }),
+    async apply(ref, params, plugin) {
+        const structureCell = StateObjectRef.resolveAndCheck(plugin.state.data, ref);
+        if (!structureCell) return {};
+
+        const { coordinateType } = params;
+        const components = {
+            [coordinateType]: await presetStaticComponent(plugin, structureCell, 'all', { label: coordinateType, tags: [coordinateType] })
+        };
+
+        const structure = structureCell.obj!.data;
+        const { update, builder } = reprBuilder(plugin, params);
+
+        const representations = {
+            [coordinateType]: builder.buildRepresentation(update, components[coordinateType], { type: 'ball-and-stick' }),
+        };
+
+        await update.commit({ revertOnError: true });
+        await updateFocusRepr(plugin, structure, params.theme?.focus?.name, params.theme?.focus?.params);
+
+        return { components, representations };
+    }
+});
+
 export function presetStaticComponent(plugin: PluginContext, structure: StateObjectRef<PluginStateObject.Molecule.Structure>, type: StaticStructureComponentType, params?: { label?: string, tags?: string[] }) {
     return plugin.builders.structure.tryCreateComponentStatic(structure, type, params);
 }
@@ -446,5 +483,6 @@ export const PresetStructureRepresentations = {
     'protein-and-nucleic': proteinAndNucleic,
     'coarse-surface': coarseSurface,
     illustrative,
+    'chemical-component': chemicalComponent
 };
 export type PresetStructureRepresentations = typeof PresetStructureRepresentations;

+ 3 - 3
src/mol-plugin-state/formats/provider.ts

@@ -8,7 +8,7 @@
 import { decodeMsgPack } from '../../mol-io/common/msgpack/decode';
 import { PluginContext } from '../../mol-plugin/context';
 import { StateObjectRef } from '../../mol-state';
-import { FileInfo } from '../../mol-util/file-info';
+import { FileNameInfo } from '../../mol-util/file-info';
 import { PluginStateObject } from '../objects';
 
 export interface DataFormatProvider<P = any, R = any, V = any> {
@@ -17,7 +17,7 @@ export interface DataFormatProvider<P = any, R = any, V = any> {
     category?: string,
     stringExtensions?: string[],
     binaryExtensions?: string[],
-    isApplicable?(info: FileInfo, data: string | Uint8Array): boolean,
+    isApplicable?(info: FileNameInfo, data: string | Uint8Array): boolean,
     parse(plugin: PluginContext, data: StateObjectRef<PluginStateObject.Data.Binary | PluginStateObject.Data.String>, params?: P): Promise<R>,
     visuals?(plugin: PluginContext, data: R): Promise<V> | undefined
 }
@@ -25,7 +25,7 @@ export interface DataFormatProvider<P = any, R = any, V = any> {
 export function DataFormatProvider<P extends DataFormatProvider>(provider: P): P { return provider; }
 
 type cifVariants = 'dscif' | 'segcif' | 'coreCif' | -1
-export function guessCifVariant(info: FileInfo, data: Uint8Array | string): cifVariants {
+export function guessCifVariant(info: FileNameInfo, data: Uint8Array | string): cifVariants {
     if (info.ext === 'bcif') {
         try {
             // TODO: find a way to run msgpackDecode only once

+ 2 - 2
src/mol-plugin-state/formats/registry.ts

@@ -5,7 +5,7 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { FileInfo } from '../../mol-util/file-info';
+import { FileNameInfo } from '../../mol-util/file-info';
 import { PluginStateObject } from '../objects';
 import { DataFormatProvider } from './provider';
 import { BuiltInTrajectoryFormats } from './trajectory';
@@ -78,7 +78,7 @@ export class DataFormatRegistry {
         this._map.delete(name);
     }
 
-    auto(info: FileInfo, dataStateObject: PluginStateObject.Data.Binary | PluginStateObject.Data.String) {
+    auto(info: FileNameInfo, dataStateObject: PluginStateObject.Data.Binary | PluginStateObject.Data.String) {
         for (let i = 0, il = this.list.length; i < il; ++i) {
             const p = this._list[i].provider;
 

+ 30 - 6
src/mol-plugin-state/manager/camera.ts

@@ -4,18 +4,22 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author Ke Ma <mark.ma@rcsb.org>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
-import { Sphere3D } from '../../mol-math/geometry';
-import { PluginContext } from '../../mol-plugin/context';
-import { PrincipalAxes } from '../../mol-math/linear-algebra/matrix/principal-axes';
 import { Camera } from '../../mol-canvas3d/camera';
-import { Loci } from '../../mol-model/loci';
-import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
 import { GraphicsRenderObject } from '../../mol-gl/render-object';
-import { StructureElement } from '../../mol-model/structure';
+import { Sphere3D } from '../../mol-math/geometry';
+import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
+import { Mat3 } from '../../mol-math/linear-algebra';
 import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
+import { PrincipalAxes } from '../../mol-math/linear-algebra/matrix/principal-axes';
+import { Loci } from '../../mol-model/loci';
+import { Structure, StructureElement } from '../../mol-model/structure';
+import { PluginContext } from '../../mol-plugin/context';
+import { PluginStateObject } from '../objects';
 import { pcaFocus } from './focus-camera/focus-first-residue';
+import { changeCameraRotation, structureLayingTransform } from './focus-camera/orient-axes';
 
 // TODO: make this customizable somewhere?
 const DefaultCameraFocusOptions = {
@@ -125,6 +129,26 @@ export class CameraManager {
         }
     }
 
+    /** Align PCA axes of `structures` (default: all loaded structures) to the screen axes. */
+    orientAxes(structures?: Structure[], durationMs?: number) {
+        if (!this.plugin.canvas3d) return;
+        if (!structures) {
+            const structCells = this.plugin.state.data.selectQ(q => q.ofType(PluginStateObject.Molecule.Structure));
+            const rootStructCells = structCells.filter(cell => cell.obj && !cell.transform.transformer.definition.isDecorator && !cell.obj.data.parent);
+            structures = rootStructCells.map(cell => cell.obj?.data).filter(struct => !!struct) as Structure[];
+        }
+        const { rotation } = structureLayingTransform(structures);
+        const newSnapshot = changeCameraRotation(this.plugin.canvas3d.camera.getSnapshot(), rotation);
+        this.setSnapshot(newSnapshot, durationMs);
+    }
+
+    /** Align Cartesian axes to the screen axes (X right, Y up). */
+    resetAxes(durationMs?: number) {
+        if (!this.plugin.canvas3d) return;
+        const newSnapshot = changeCameraRotation(this.plugin.canvas3d.camera.getSnapshot(), Mat3.Identity);
+        this.setSnapshot(newSnapshot, durationMs);
+    }
+
     setSnapshot(snapshot: Partial<Camera.Snapshot>, durationMs?: number) {
         // TODO: setState and requestCameraReset are very similar now: unify them?
         this.plugin.canvas3d?.requestCameraReset({ snapshot, durationMs });

+ 218 - 0
src/mol-plugin-state/manager/focus-camera/orient-axes.ts

@@ -0,0 +1,218 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Camera } from '../../../mol-canvas3d/camera';
+import { Mat3, Vec3 } from '../../../mol-math/linear-algebra';
+import { PrincipalAxes } from '../../../mol-math/linear-algebra/matrix/principal-axes';
+import { Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
+
+
+/** Minimum number of atoms necessary for running PCA.
+ * If enough atoms cannot be selected, XYZ axes will be used instead of PCA axes. */
+const MIN_ATOMS_FOR_PCA = 3;
+
+/** Rotation matrices for the basic rotations by 90 degrees */
+export const ROTATION_MATRICES = {
+    // The order of elements in the matrices in column-wise (F-style)
+    identity: Mat3.create(1, 0, 0, 0, 1, 0, 0, 0, 1),
+    rotX90: Mat3.create(1, 0, 0, 0, 0, 1, 0, -1, 0),
+    rotY90: Mat3.create(0, 0, -1, 0, 1, 0, 1, 0, 0),
+    rotZ90: Mat3.create(0, 1, 0, -1, 0, 0, 0, 0, 1),
+    rotX270: Mat3.create(1, 0, 0, 0, 0, -1, 0, 1, 0),
+    rotY270: Mat3.create(0, 0, 1, 0, 1, 0, -1, 0, 0),
+    rotZ270: Mat3.create(0, -1, 0, 1, 0, 0, 0, 0, 1),
+    rotX180: Mat3.create(1, 0, 0, 0, -1, 0, 0, 0, -1),
+    rotY180: Mat3.create(-1, 0, 0, 0, 1, 0, 0, 0, -1),
+    rotZ180: Mat3.create(-1, 0, 0, 0, -1, 0, 0, 0, 1),
+};
+
+
+/** Return transformation which will align the PCA axes of an atomic structure
+ * (or multiple structures) to the Cartesian axes x, y, z
+ * (transformed = rotation * (coords - origin)).
+ *
+ * There are always 4 equally good rotations to do this (4 flips).
+ * If `referenceRotation` is provided, select the one nearest to `referenceRotation`.
+ * Otherwise use arbitrary rules to ensure the orientation after transform does not depend on the original orientation.
+ */
+export function structureLayingTransform(structures: Structure[], referenceRotation?: Mat3): { rotation: Mat3, origin: Vec3 } {
+    const coords = smartSelectCoords(structures, MIN_ATOMS_FOR_PCA);
+    return layingTransform(coords, referenceRotation);
+}
+
+/** Return transformation which will align the PCA axes of a sequence
+ * of points to the Cartesian axes x, y, z
+ * (transformed = rotation * (coords - origin)).
+ *
+ * `coords` is a flattened array of 3D coordinates (i.e. the first 3 values are x, y, and z of the first point etc.).
+ *
+ * There are always 4 equally good rotations to do this (4 flips).
+ * If `referenceRotation` is provided, select the one nearest to `referenceRotation`.
+ * Otherwise use arbitrary rules to ensure the orientation after transform does not depend on the original orientation.
+ */
+export function layingTransform(coords: number[], referenceRotation?: Mat3): { rotation: Mat3, origin: Vec3 } {
+    if (coords.length === 0) {
+        console.warn('Skipping PCA, no atoms');
+        return { rotation: ROTATION_MATRICES.identity, origin: Vec3.zero() };
+    }
+    const axes = PrincipalAxes.calculateMomentsAxes(coords);
+    const normAxes = PrincipalAxes.calculateNormalizedAxes(axes);
+    const R = mat3FromRows(normAxes.dirA, normAxes.dirB, normAxes.dirC);
+    avoidMirrorRotation(R); // The SVD implementation seems to always provide proper rotation, but just to be sure
+    const flip = referenceRotation ? minimalFlip(R, referenceRotation) : canonicalFlip(coords, R, axes.origin);
+    Mat3.mul(R, flip, R);
+    return { rotation: R, origin: normAxes.origin };
+}
+
+/** Try these selection strategies until having at least `minAtoms` atoms:
+ * 1. only trace atoms (e.g. C-alpha and O3')
+ * 2. all non-hydrogen atoms with exception of water (HOH)
+ * 3. all atoms
+ * Return the coordinates in a flattened array (in triples).
+ * If the total number of atoms is less than `minAtoms`, return only those. */
+function smartSelectCoords(structures: Structure[], minAtoms: number): number[] {
+    let coords: number[];
+    coords = selectCoords(structures, { onlyTrace: true });
+    if (coords.length >= 3 * minAtoms) return coords;
+
+    coords = selectCoords(structures, { skipHydrogens: true, skipWater: true });
+    if (coords.length >= 3 * minAtoms) return coords;
+
+    coords = selectCoords(structures, {});
+    return coords;
+}
+
+/** Select coordinates of atoms in `structures` as a flattened array (in triples).
+ * If `onlyTrace`, include only trace atoms (CA, O3');
+ * if `skipHydrogens`, skip all hydrogen atoms;
+ * if `skipWater`, skip all water residues. */
+function selectCoords(structures: Structure[], options: { onlyTrace?: boolean, skipHydrogens?: boolean, skipWater?: boolean }): number[] {
+    const { onlyTrace, skipHydrogens, skipWater } = options;
+    const { x, y, z, type_symbol, label_comp_id } = StructureProperties.atom;
+    const coords: number[] = [];
+    for (const struct of structures) {
+        const loc = StructureElement.Location.create(struct);
+        for (const unit of struct.units) {
+            loc.unit = unit;
+            const elements = onlyTrace ? unit.polymerElements : unit.elements;
+            for (let i = 0; i < elements.length; i++) {
+                loc.element = elements[i];
+                if (skipHydrogens && type_symbol(loc) === 'H') continue;
+                if (skipWater && label_comp_id(loc) === 'HOH') continue;
+                coords.push(x(loc), y(loc), z(loc));
+            }
+        }
+    }
+    return coords;
+}
+
+/** Return a flip around XYZ axes which minimizes the difference between flip*rotation and referenceRotation. */
+function minimalFlip(rotation: Mat3, referenceRotation: Mat3): Mat3 {
+    let bestFlip = ROTATION_MATRICES.identity;
+    let bestScore = 0; // there will always be at least one positive score
+    const aux = Mat3();
+    for (const flip of [ROTATION_MATRICES.identity, ROTATION_MATRICES.rotX180, ROTATION_MATRICES.rotY180, ROTATION_MATRICES.rotZ180]) {
+        const score = Mat3.innerProduct(Mat3.mul(aux, flip, rotation), referenceRotation);
+        if (score > bestScore) {
+            bestFlip = flip;
+            bestScore = score;
+        }
+    }
+    return bestFlip;
+}
+
+/** Return a rotation matrix (flip) that should be applied to `coords` (after being rotated by `rotation`)
+ * to ensure a deterministic "canonical" rotation.
+ * There are 4 flips to choose from (one identity and three 180-degree rotations around the X, Y, and Z axes).
+ * One of these 4 possible results is selected so that:
+ *   1) starting and ending coordinates tend to be more in front (z > 0), middle more behind (z < 0).
+ *   2) starting coordinates tend to be more left-top (x < y), ending more right-bottom (x > y).
+ * These rules are arbitrary, but try to avoid ties for at least some basic symmetries.
+ * Provided `origin` parameter MUST be the mean of the coordinates, otherwise it will not work!
+ */
+function canonicalFlip(coords: number[], rotation: Mat3, origin: Vec3): Mat3 {
+    const pcaX = Vec3.create(Mat3.getValue(rotation, 0, 0), Mat3.getValue(rotation, 0, 1), Mat3.getValue(rotation, 0, 2));
+    const pcaY = Vec3.create(Mat3.getValue(rotation, 1, 0), Mat3.getValue(rotation, 1, 1), Mat3.getValue(rotation, 1, 2));
+    const pcaZ = Vec3.create(Mat3.getValue(rotation, 2, 0), Mat3.getValue(rotation, 2, 1), Mat3.getValue(rotation, 2, 2));
+    const n = Math.floor(coords.length / 3);
+    const v = Vec3();
+    let xCum = 0;
+    let yCum = 0;
+    let zCum = 0;
+    for (let i = 0; i < n; i++) {
+        Vec3.fromArray(v, coords, 3 * i);
+        Vec3.sub(v, v, origin);
+        xCum += i * Vec3.dot(v, pcaX);
+        yCum += i * Vec3.dot(v, pcaY);
+        zCum += veeSlope(i, n) * Vec3.dot(v, pcaZ);
+        // Thanks to subtracting `origin` from `coords` the slope functions `i` and `veeSlope(i, n)`
+        // don't have to have zero sum (can be shifted up or down):
+        //     sum{(slope[i]+shift)*(coords[i]-origin).PCA} =
+        //     = sum{slope[i]*coords[i].PCA - slope[i]*origin.PCA + shift*coords[i].PCA - shift*origin.PCA} =
+        //     = sum{slope[i]*(coords[i]-origin).PCA} + shift*sum{coords[i]-origin}.PCA =
+        //     = sum{slope[i]*(coords[i]-origin).PCA}
+    }
+    const wrongFrontBack = zCum < 0;
+    const wrongLeftTopRightBottom = wrongFrontBack ? xCum + yCum < 0 : xCum - yCum < 0;
+    if (wrongLeftTopRightBottom && wrongFrontBack) {
+        return ROTATION_MATRICES.rotY180; // flip around Y = around X then Z
+    } else if (wrongFrontBack) {
+        return ROTATION_MATRICES.rotX180; // flip around X
+    } else if (wrongLeftTopRightBottom) {
+        return ROTATION_MATRICES.rotZ180; // flip around Z
+    } else {
+        return ROTATION_MATRICES.identity; // do not flip
+    }
+}
+
+/** Auxiliary function defined for i in [0, n), linearly decreasing from 0 to n/2
+ * and then increasing back from n/2 to n, resembling letter V. */
+function veeSlope(i: number, n: number) {
+    const mid = Math.floor(n / 2);
+    if (i < mid) {
+        if (n % 2) return mid - i;
+        else return mid - i - 1;
+    } else {
+        return i - mid;
+    }
+}
+
+function mat3FromRows(row0: Vec3, row1: Vec3, row2: Vec3): Mat3 {
+    const m = Mat3();
+    Mat3.setValue(m, 0, 0, row0[0]);
+    Mat3.setValue(m, 0, 1, row0[1]);
+    Mat3.setValue(m, 0, 2, row0[2]);
+    Mat3.setValue(m, 1, 0, row1[0]);
+    Mat3.setValue(m, 1, 1, row1[1]);
+    Mat3.setValue(m, 1, 2, row1[2]);
+    Mat3.setValue(m, 2, 0, row2[0]);
+    Mat3.setValue(m, 2, 1, row2[1]);
+    Mat3.setValue(m, 2, 2, row2[2]);
+    return m;
+}
+
+/** Check if a rotation matrix includes mirroring and invert Z axis in such case, to ensure a proper rotation (in-place). */
+function avoidMirrorRotation(rot: Mat3) {
+    if (Mat3.determinant(rot) < 0) {
+        Mat3.setValue(rot, 2, 0, -Mat3.getValue(rot, 2, 0));
+        Mat3.setValue(rot, 2, 1, -Mat3.getValue(rot, 2, 1));
+        Mat3.setValue(rot, 2, 2, -Mat3.getValue(rot, 2, 2));
+    }
+}
+
+/** Return a new camera snapshot with the same target and camera distance from the target as `old`
+ * but with diferent orientation.
+ * The actual rotation applied to the camera is the inverse of `rotation`,
+ * which creates the same effect as if `rotation` were applied to the whole scene without moving the camera.
+ * The rotation is relative to the default camera orientation (not to the current orientation). */
+export function changeCameraRotation(old: Camera.Snapshot, rotation: Mat3): Camera.Snapshot {
+    const cameraRotation = Mat3.invert(Mat3(), rotation);
+    const dist = Vec3.distance(old.position, old.target);
+    const relPosition = Vec3.transformMat3(Vec3(), Vec3.create(0, 0, dist), cameraRotation);
+    const newUp = Vec3.transformMat3(Vec3(), Vec3.create(0, 1, 0), cameraRotation);
+    const newPosition = Vec3.add(Vec3(), old.target, relPosition);
+    return { ...old, position: newPosition, up: newUp };
+}

+ 2 - 1
src/mol-plugin-state/manager/loci-label.ts

@@ -11,7 +11,8 @@ import { Representation } from '../../mol-repr/representation';
 import { MarkerAction } from '../../mol-util/marker-action';
 import { arrayRemoveAtInPlace } from '../../mol-util/array';
 
-export type LociLabel = JSX.Element | string
+// any represents React element. For compatibility to including the type
+export type LociLabel = string | any
 export type LociLabelProvider = {
     label: (loci: Loci, repr?: Representation<any>) => LociLabel | undefined
     group?: (entry: LociLabel) => string

+ 7 - 1
src/mol-plugin-state/manager/structure/component.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 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>
@@ -54,6 +54,12 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
         return this.currentStructures[0];
     }
 
+    // To be used only from PluginState.setSnapshot
+    _setSnapshotState(options: StructureComponentManager.Options) {
+        this.updateState({ options });
+        this.events.optionsUpdated.next(void 0);
+    }
+
     async setOptions(options: StructureComponentManager.Options) {
         const interactionChanged = options.interactions !== this.state.options.interactions;
         this.updateState({ options });

+ 1 - 1
src/mol-plugin-state/transforms/data.ts

@@ -282,7 +282,7 @@ const ParseCif = PluginStateTransform.BuiltIn({
 })({
     apply({ a }) {
         return Task.create('Parse CIF', async ctx => {
-            const parsed = await (SO.Data.String.is(a) ? CIF.parse(a.data) : CIF.parseBinary(a.data)).runInContext(ctx);
+            const parsed = await (typeof a.data === 'string' ? CIF.parse(a.data) : CIF.parseBinary(a.data)).runInContext(ctx);
             if (parsed.isError) throw new Error(parsed.message);
             return new SO.Format.Cif(parsed.result);
         });

+ 29 - 9
src/mol-plugin-state/transforms/model.ts

@@ -272,25 +272,45 @@ const TrajectoryFromMmCif = PluginStateTransform.BuiltIn({
     params(a) {
         if (!a) {
             return {
-                blockHeader: PD.Optional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.' }))
+                loadAllBlocks: PD.Optional(PD.Boolean(false, { description: 'If True, ignore Block Header parameter and parse all datablocks into a single trajectory.' })),
+                blockHeader: PD.Optional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.', hideIf: p => p.loadAllBlocks === true })),
             };
         }
         const { blocks } = a.data;
         return {
-            blockHeader: PD.Optional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
+            loadAllBlocks: PD.Optional(PD.Boolean(false, { description: 'If True, ignore Block Header parameter and parse all data blocks into a single trajectory.' })),
+            blockHeader: PD.Optional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse', hideIf: p => p.loadAllBlocks === true })),
         };
     }
 })({
     isApplicable: a => a.data.blocks.length > 0,
     apply({ a, params }) {
         return Task.create('Parse mmCIF', async ctx => {
-            const header = params.blockHeader || a.data.blocks[0].header;
-            const block = a.data.blocks.find(b => b.header === header);
-            if (!block) throw new Error(`Data block '${[header]}' not found.`);
-            const models = block.categoryNames.includes('chem_comp_atom') ? await trajectoryFromCCD(block).runInContext(ctx) : await trajectoryFromMmCIF(block).runInContext(ctx);
-            if (models.frameCount === 0) throw new Error('No models found.');
-            const props = trajectoryProps(models);
-            return new SO.Molecule.Trajectory(models, props);
+            let trajectory: Trajectory;
+            if (params.loadAllBlocks) {
+                const models: Model[] = [];
+                for (const block of a.data.blocks) {
+                    if (ctx.shouldUpdate) {
+                        await ctx.update(`Parsing ${block.header}...`);
+                    }
+                    const t = await trajectoryFromMmCIF(block).runInContext(ctx);
+                    for (let i = 0; i < t.frameCount; i++) {
+                        models.push(await Task.resolveInContext(t.getFrameAtIndex(i), ctx));
+                    }
+                }
+                trajectory = new ArrayTrajectory(models);
+            } else {
+                const header = params.blockHeader || a.data.blocks[0].header;
+                const block = a.data.blocks.find(b => b.header === header);
+                if (!block) throw new Error(`Data block '${[header]}' not found.`);
+                const models = block.categoryNames.includes('chem_comp_atom') ? await trajectoryFromCCD(block).runInContext(ctx) : await trajectoryFromMmCIF(block).runInContext(ctx);
+                if (models.frameCount === 0) throw new Error('No models found.');
+                const props = trajectoryProps(models);
+                return new SO.Molecule.Trajectory(models, props);
+            }
+            if (trajectory.frameCount === 0) throw new Error('No models found.');
+            const props = trajectoryProps(trajectory);
+            return new SO.Molecule.Trajectory(trajectory, props);
         });
     }
 });

+ 6 - 6
src/mol-plugin-state/transforms/representation.ts

@@ -351,7 +351,7 @@ const OverpaintStructureRepresentation3DFromScript = PluginStateTransform.BuiltI
 
         const newGeometryVersion = a.data.repr.geometryVersion;
         // smoothing needs to be re-calculated when geometry changes
-        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Recreate;
 
         const oldOverpaint = b.data.state.overpaint!;
         const newOverpaint = Overpaint.ofScript(newParams.layers, newStructure);
@@ -409,7 +409,7 @@ const OverpaintStructureRepresentation3DFromBundle = PluginStateTransform.BuiltI
 
         const newGeometryVersion = a.data.repr.geometryVersion;
         // smoothing needs to be re-calculated when geometry changes
-        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Recreate;
 
         const oldOverpaint = b.data.state.overpaint!;
         const newOverpaint = Overpaint.ofBundle(newParams.layers, newStructure);
@@ -464,7 +464,7 @@ const TransparencyStructureRepresentation3DFromScript = PluginStateTransform.Bui
 
         const newGeometryVersion = a.data.repr.geometryVersion;
         // smoothing needs to be re-calculated when geometry changes
-        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Recreate;
 
         const oldTransparency = b.data.state.transparency!;
         const newTransparency = Transparency.ofScript(newParams.layers, newStructure);
@@ -520,7 +520,7 @@ const TransparencyStructureRepresentation3DFromBundle = PluginStateTransform.Bui
 
         const newGeometryVersion = a.data.repr.geometryVersion;
         // smoothing needs to be re-calculated when geometry changes
-        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Recreate;
 
         const oldTransparency = b.data.state.transparency!;
         const newTransparency = Transparency.ofBundle(newParams.layers, newStructure);
@@ -577,7 +577,7 @@ const SubstanceStructureRepresentation3DFromScript = PluginStateTransform.BuiltI
 
         const newGeometryVersion = a.data.repr.geometryVersion;
         // smoothing needs to be re-calculated when geometry changes
-        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Recreate;
 
         const oldSubstance = b.data.state.substance!;
         const newSubstance = Substance.ofScript(newParams.layers, newStructure);
@@ -635,7 +635,7 @@ const SubstanceStructureRepresentation3DFromBundle = PluginStateTransform.BuiltI
 
         const newGeometryVersion = a.data.repr.geometryVersion;
         // smoothing needs to be re-calculated when geometry changes
-        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Recreate;
 
         const oldSubstance = b.data.state.substance!;
         const newSubstance = Substance.ofBundle(newParams.layers, newStructure);

+ 2 - 7
src/mol-plugin-ui/controls.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
@@ -240,14 +240,9 @@ export class SelectionViewportControls extends PluginUIComponent {
         this.subscribe(this.plugin.behaviors.interaction.selectionMode, () => this.forceUpdate());
     }
 
-    onMouseMove = (e: React.MouseEvent) => {
-        // ignore mouse moves when no button is held
-        if (e.buttons === 0) e.stopPropagation();
-    };
-
     render() {
         if (!this.plugin.selectionMode) return null;
-        return <div className='msp-selection-viewport-controls' onMouseMove={this.onMouseMove}>
+        return <div className='msp-selection-viewport-controls'>
             <StructureSelectionActionsControls />
         </div>;
     }

+ 2 - 2
src/mol-plugin-ui/controls/slider.tsx

@@ -626,9 +626,9 @@ export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState
         const value = bounds[handle];
 
         let direction = 0;
-        if (bounds[handle + 1] - value < threshold!) {
+        if (bounds[handle + 1] - value < +threshold!) {
             direction = +1;
-        } else if (value - bounds[handle - 1] < threshold!) {
+        } else if (value - bounds[handle - 1] < +threshold!) {
             direction = -1;
         }
 

+ 3 - 2
src/mol-plugin-ui/left-panel.tsx

@@ -6,6 +6,7 @@
  */
 
 import * as React from 'react';
+import { throttleTime } from 'rxjs';
 import { Canvas3DParams } from '../mol-canvas3d/canvas3d';
 import { PluginCommands } from '../mol-plugin/commands';
 import { LeftPanelTabName } from '../mol-plugin/layout';
@@ -13,12 +14,12 @@ import { StateTransform } from '../mol-state';
 import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { PluginUIComponent } from './base';
 import { IconButton, SectionHeader } from './controls/common';
+import { AccountTreeOutlinedSvg, DeleteOutlinedSvg, HelpOutlineSvg, HomeOutlinedSvg, SaveOutlinedSvg, TuneSvg } from './controls/icons';
 import { ParameterControls } from './controls/parameters';
 import { StateObjectActions } from './state/actions';
 import { RemoteStateSnapshots, StateSnapshots } from './state/snapshots';
 import { StateTree } from './state/tree';
 import { HelpContent } from './viewport/help';
-import { HomeOutlinedSvg, AccountTreeOutlinedSvg, TuneSvg, HelpOutlineSvg, SaveOutlinedSvg, DeleteOutlinedSvg } from './controls/icons';
 
 export class CustomImportControls extends PluginUIComponent<{ initiallyCollapsed?: boolean }> {
     componentDidMount() {
@@ -142,7 +143,7 @@ class FullSettings extends PluginUIComponent {
         this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
 
         if (this.plugin.canvas3d) {
-            this.subscribe(this.plugin.canvas3d.camera.stateChanged, state => {
+            this.subscribe(this.plugin.canvas3d.camera.stateChanged.pipe(throttleTime(500, undefined, { leading: true, trailing: true })), state => {
                 if (state.radiusMax !== undefined || state.radius !== undefined) {
                     this.forceUpdate();
                 }

+ 31 - 4
src/mol-plugin-ui/skin/base/components/viewport.scss

@@ -7,7 +7,7 @@
     background: $default-background;
 
     .msp-btn-link {
-        background: rgba(0,0,0,0.2);
+        background: rgba(0, 0, 0, 0.2);
     }
 
 }
@@ -25,14 +25,14 @@
     bottom: 0;
     -webkit-user-select: none;
     user-select: none;
-    -webkit-tap-highlight-color: rgba(0,0,0,0);
+    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
     -webkit-touch-callout: none;
     touch-action: manipulation;
 
     > canvas {
         background-color: $default-background;
         background-image: linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey),
-        linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
+            linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
         background-size: 60px 60px;
         background-position: 0 0, 30px 30px;
     }
@@ -82,6 +82,33 @@
     height: 100%;
 }
 
+.msp-hover-box-wrapper {
+    position: relative;
+
+    .msp-hover-box-body {
+        visibility: hidden;
+        position: absolute;
+        right: $row-height + 4px;
+        top: 0;
+        width: 100px;
+        background-color: $default-background;
+    }
+
+    .msp-hover-box-spacer {
+        visibility: hidden;
+        position: absolute;
+        right: $row-height;
+        top: 0;
+        width: 4px;
+        height: $row-height;
+    }
+
+    &:hover .msp-hover-box-body,
+    &:hover .msp-hover-box-spacer {
+        visibility: visible;
+    }
+}
+
 .msp-viewport-controls-panel {
     width: 290px;
     top: 0;
@@ -134,4 +161,4 @@
     font-size: 85%;
     display: inline-block;
     color: $highlight-info-additional-font-color;
-}
+}

+ 1 - 1
src/mol-plugin-ui/state/snapshots.tsx

@@ -187,7 +187,7 @@ export class LocalStateSnapshotList extends PluginUIComponent<{}, {}> {
             if (image) {
                 items.push(<li key={`${e!.snapshot.id}-image`} className='msp-state-image-row'>
                     <Button data-id={e!.snapshot.id} onClick={this.apply}>
-                        <img src={URL.createObjectURL(image)}/>
+                        <img draggable={false} src={URL.createObjectURL(image)}/>
                     </Button>
                 </li>);
             }

+ 6 - 1
src/mol-plugin-ui/structure/measurements.tsx

@@ -3,6 +3,7 @@
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Jason Pattle <jpattle.exscientia.co.uk>
  */
 
 import * as React from 'react';
@@ -11,6 +12,7 @@ import { StructureElement } from '../../mol-model/structure';
 import { StructureMeasurementCell, StructureMeasurementOptions, StructureMeasurementParams } from '../../mol-plugin-state/manager/structure/measurement';
 import { StructureSelectionHistoryEntry } from '../../mol-plugin-state/manager/structure/selection';
 import { PluginCommands } from '../../mol-plugin/commands';
+import { PluginConfig } from '../../mol-plugin/config';
 import { AngleData } from '../../mol-repr/shape/loci/angle';
 import { DihedralData } from '../../mol-repr/shape/loci/dihedral';
 import { DistanceData } from '../../mol-repr/shape/loci/distance';
@@ -208,13 +210,16 @@ export class MeasurementControls extends PurePluginUIComponent<{}, { isBusy: boo
             entries.push(this.historyEntry(history[i], i + 1));
         }
 
+        const shouldShowToggleHint = this.plugin.config.get(PluginConfig.Viewport.ShowSelectionMode);
+        const toggleHint = shouldShowToggleHint ? (<>{' '}(toggle <ToggleSelectionModeButton inline /> mode)</>) : null;
+
         return <>
             <ActionMenu items={this.actions} onSelect={this.selectAction} />
             {entries.length > 0 && <div className='msp-control-offset'>
                 {entries}
             </div>}
             {entries.length === 0 && <div className='msp-control-offset msp-help-text'>
-                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections (toggle <ToggleSelectionModeButton inline /> mode)</div>
+                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections{toggleHint}</div>
             </div>}
         </>;
     }

+ 31 - 5
src/mol-plugin-ui/structure/quick-styles.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2022-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -56,11 +56,24 @@ export class QuickStyles extends PurePluginUIComponent {
                 postprocessing: {
                     outline: {
                         name: 'on',
-                        params: { scale: 1, color: Color(0x000000), threshold: 0.25, includeTransparent: true }
+                        params: {
+                            scale: 1,
+                            color: Color(0x000000),
+                            threshold: 0.25,
+                            includeTransparent: true,
+                        }
                     },
                     occlusion: {
                         name: 'on',
-                        params: { bias: 0.8, blurKernelSize: 15, radius: 5, samples: 32, resolutionScale: 1 }
+                        params: {
+                            multiScale: { name: 'off', params: {} },
+                            radius: 5,
+                            bias: 0.8,
+                            blurKernelSize: 15,
+                            samples: 32,
+                            resolutionScale: 1,
+                            color: Color(0x000000),
+                        }
                     },
                     shadow: { name: 'off', params: {} },
                 }
@@ -79,13 +92,26 @@ export class QuickStyles extends PurePluginUIComponent {
                         name: 'on',
                         params: pp.outline.name === 'on'
                             ? pp.outline.params
-                            : { scale: 1, color: Color(0x000000), threshold: 0.33, includeTransparent: true }
+                            : {
+                                scale: 1,
+                                color: Color(0x000000),
+                                threshold: 0.33,
+                                includeTransparent: true,
+                            }
                     },
                     occlusion: {
                         name: 'on',
                         params: pp.occlusion.name === 'on'
                             ? pp.occlusion.params
-                            : { bias: 0.8, blurKernelSize: 15, radius: 5, samples: 32, resolutionScale: 1 }
+                            : {
+                                multiScale: { name: 'off', params: {} },
+                                radius: 5,
+                                bias: 0.8,
+                                blurKernelSize: 15,
+                                samples: 32,
+                                resolutionScale: 1,
+                                color: Color(0x000000),
+                            }
                     },
                     shadow: { name: 'off', params: {} },
                 }

+ 3 - 1
src/mol-plugin-ui/structure/selection.tsx

@@ -3,6 +3,7 @@
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Jason Pattle <jpattle.exscientia.co.uk>
  */
 
 import * as React from 'react';
@@ -12,6 +13,7 @@ import { InteractivityManager } from '../../mol-plugin-state/manager/interactivi
 import { StructureComponentManager } from '../../mol-plugin-state/manager/structure/component';
 import { StructureComponentRef, StructureRef } from '../../mol-plugin-state/manager/structure/hierarchy-state';
 import { StructureSelectionModifier } from '../../mol-plugin-state/manager/structure/selection';
+import { PluginConfig } from '../../mol-plugin/config';
 import { PluginContext } from '../../mol-plugin/context';
 import { compileIdListSelection } from '../../mol-script/util/id-list';
 import { memoizeLatest } from '../../mol-util/memoize';
@@ -272,7 +274,7 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str
                 <IconButton svg={RestoreSvg} onClick={this.undo} disabled={!this.state.canUndo || this.isDisabled} title={undoTitle} />
 
                 <ToggleButton icon={HelpOutlineSvg} title='Show/hide help' toggle={this.toggleHelp} style={{ marginLeft: '10px' }} isSelected={this.state.action === 'help'} />
-                <IconButton svg={CancelOutlinedSvg} title='Turn selection mode off' onClick={this.turnOff} />
+                {this.plugin.config.get(PluginConfig.Viewport.ShowSelectionMode) && (<IconButton svg={CancelOutlinedSvg} title='Turn selection mode off' onClick={this.turnOff} />)}
             </div>
             {children}
         </>;

+ 8 - 2
src/mol-plugin-ui/structure/superposition.tsx

@@ -16,6 +16,7 @@ import { StructureSelectionHistoryEntry } from '../../mol-plugin-state/manager/s
 import { PluginStateObject } from '../../mol-plugin-state/objects';
 import { StateTransforms } from '../../mol-plugin-state/transforms';
 import { PluginCommands } from '../../mol-plugin/commands';
+import { PluginConfig } from '../../mol-plugin/config';
 import { StateObjectCell, StateObjectRef } from '../../mol-state';
 import { elementLabel, structureElementStatsLabel } from '../../mol-theme/label';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
@@ -324,6 +325,11 @@ export class SuperpositionControls extends PurePluginUIComponent<{ }, Superposit
         return entries;
     }
 
+    toggleHint() {
+        const shouldShowToggleHint = this.plugin.config.get(PluginConfig.Viewport.ShowSelectionMode);
+        return shouldShowToggleHint ? (<>{' '}(toggle <ToggleSelectionModeButton inline /> mode)</>) : null;
+    }
+
     addByChains() {
         const entries = this.chainEntries;
         return <>
@@ -331,7 +337,7 @@ export class SuperpositionControls extends PurePluginUIComponent<{ }, Superposit
                 {entries.map((e, i) => this.lociEntry(e, i))}
             </div>}
             {entries.length < 2 && <div className='msp-control-offset msp-help-text'>
-                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add 2 or more selections (toggle <ToggleSelectionModeButton inline /> mode) from separate structures. Selections must be limited to single polymer chains or residues therein.</div>
+                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add 2 or more selections{this.toggleHint()} from separate structures. Selections must be limited to single polymer chains or residues therein.</div>
             </div>}
             {entries.length > 1 && <Button title='Superpose structures by selected chains.' className='msp-btn-commit msp-btn-commit-on' onClick={this.superposeChains} style={{ marginTop: '1px' }}>
                 Superpose
@@ -346,7 +352,7 @@ export class SuperpositionControls extends PurePluginUIComponent<{ }, Superposit
                 {entries.map((e, i) => this.atomsLociEntry(e, i))}
             </div>}
             {entries.length < 2 && <div className='msp-control-offset msp-help-text'>
-                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add 1 or more selections (toggle <ToggleSelectionModeButton inline /> mode) from
+                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add 1 or more selections{this.toggleHint()} from
                 separate structures. Selections must be limited to single atoms.</div>
             </div>}
             {entries.length > 1 && <Button title='Superpose structures by selected atoms.' className='msp-btn-commit msp-btn-commit-on' onClick={this.superposeAtoms} style={{ marginTop: '1px' }}>

+ 46 - 15
src/mol-plugin-ui/viewport.tsx

@@ -1,16 +1,18 @@
 /**
- * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import * as React from 'react';
+import { throttleTime } from 'rxjs';
 import { PluginCommands } from '../mol-plugin/commands';
 import { PluginConfig } from '../mol-plugin/config';
 import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { PluginUIComponent } from './base';
-import { ControlGroup, IconButton } from './controls/common';
+import { Button, ControlGroup, IconButton } from './controls/common';
 import { AutorenewSvg, BuildOutlinedSvg, CameraOutlinedSvg, CloseSvg, FullscreenSvg, TuneSvg } from './controls/icons';
 import { ToggleSelectionModeButton } from './structure/selection';
 import { ViewportCanvas } from './viewport/canvas';
@@ -19,19 +21,23 @@ import { SimpleSettingsControl } from './viewport/simple-settings';
 
 interface ViewportControlsState {
     isSettingsExpanded: boolean,
-    isScreenshotExpanded: boolean
+    isScreenshotExpanded: boolean,
+    isCameraResetEnabled: boolean
 }
 
 interface ViewportControlsProps {
 }
 
 export class ViewportControls extends PluginUIComponent<ViewportControlsProps, ViewportControlsState> {
-    private allCollapsedState: ViewportControlsState = {
+    private allCollapsedState = {
         isSettingsExpanded: false,
-        isScreenshotExpanded: false
+        isScreenshotExpanded: false,
     };
 
-    state = { ...this.allCollapsedState } as ViewportControlsState;
+    state: ViewportControlsState = {
+        ...this.allCollapsedState,
+        isCameraResetEnabled: true,
+    };
 
     resetCamera = () => {
         PluginCommands.Camera.Reset(this.plugin, {});
@@ -39,7 +45,7 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
 
     private toggle(panel: keyof ViewportControlsState) {
         return (e?: React.MouseEvent<HTMLButtonElement>) => {
-            this.setState({ ...this.allCollapsedState, [panel]: !this.state[panel] });
+            this.setState(old => ({ ...old, ...this.allCollapsedState, [panel]: !this.state[panel] }));
             e?.currentTarget.blur();
         };
     }
@@ -67,26 +73,51 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
         this.plugin.helpers.viewportScreenshot?.download();
     };
 
+    enableCameraReset = (enable: boolean) => {
+        this.setState(old => ({ ...old, isCameraResetEnabled: enable }));
+    };
+
     componentDidMount() {
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
         this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
+        if (this.plugin.canvas3d) {
+            this.subscribe(
+                this.plugin.canvas3d.camera.stateChanged.pipe(throttleTime(500, undefined, { leading: true, trailing: true })),
+                snapshot => this.enableCameraReset(snapshot.radius !== 0 && snapshot.radiusMax !== 0)
+            );
+        }
     }
 
     icon(icon: React.FC, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title: string, isOn = true) {
         return <IconButton svg={icon} toggleState={isOn} onClick={onClick} title={title} style={{ background: 'transparent' }} />;
     }
 
-    onMouseMove = (e: React.MouseEvent) => {
-        // ignore mouse moves when no button is held
-        if (e.buttons === 0) e.stopPropagation();
-    };
-
     render() {
-        return <div className={'msp-viewport-controls'} onMouseMove={this.onMouseMove}>
+        return <div className={'msp-viewport-controls'}>
             <div className='msp-viewport-controls-buttons'>
-                <div>
+                <div className='msp-hover-box-wrapper'>
                     <div className='msp-semi-transparent-background' />
-                    {this.icon(AutorenewSvg, this.resetCamera, 'Reset Camera')}
+                    {this.icon(AutorenewSvg, this.resetCamera, 'Reset Zoom')}
+                    <div className='msp-hover-box-body'>
+                        <div className='msp-flex-column'>
+                            <div className='msp-flex-row'>
+                                <Button onClick={() => this.resetCamera()} disabled={!this.state.isCameraResetEnabled} title='Set camera zoom to fit the visible scene into view'>
+                                    Reset Zoom
+                                </Button>
+                            </div>
+                            <div className='msp-flex-row'>
+                                <Button onClick={() => PluginCommands.Camera.OrientAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align principal component axes of the loaded structures to the screen axes (“lay flat”)'>
+                                    Orient Axes
+                                </Button>
+                            </div>
+                            <div className='msp-flex-row'>
+                                <Button onClick={() => PluginCommands.Camera.ResetAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align Cartesian axes to the screen axes'>
+                                    Reset Axes
+                                </Button>
+                            </div>
+                        </div>
+                    </div>
+                    <div className='msp-hover-box-spacer'></div>
                 </div>
                 <div>
                     <div className='msp-semi-transparent-background' />

+ 1 - 1
src/mol-plugin-ui/viewport/help.tsx

@@ -99,7 +99,7 @@ export class ViewportHelpContent extends PluginUIComponent<{ selectOnly?: boolea
             {(!this.props.selectOnly && this.plugin.canvas3d) && <HelpGroup key='trackball' header='Moving in 3D'>
                 <BindingsHelp bindings={this.plugin.canvas3d.props.trackball.bindings} />
             </HelpGroup>}
-            {!!interactionBindings && <HelpGroup key='interactions' header='Mouse Controls'>
+            {!!interactionBindings && <HelpGroup key='interactions' header='Mouse & Key Controls'>
                 <BindingsHelp bindings={interactionBindings} />
             </HelpGroup>}
         </>;

+ 2 - 1
src/mol-plugin-ui/viewport/simple-settings.tsx

@@ -6,6 +6,7 @@
  */
 
 import { produce } from 'immer';
+import { throttleTime } from 'rxjs';
 import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
 import { PluginCommands } from '../../mol-plugin/commands';
 import { PluginConfig } from '../../mol-plugin/config';
@@ -26,7 +27,7 @@ export class SimpleSettingsControl extends PluginUIComponent {
 
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
 
-        this.subscribe(this.plugin.canvas3d!.camera.stateChanged, state => {
+        this.subscribe(this.plugin.canvas3d!.camera.stateChanged.pipe(throttleTime(500, undefined, { leading: true, trailing: true })), state => {
             if (state.radiusMax !== undefined || state.radius !== undefined) {
                 this.forceUpdate();
             }

+ 107 - 8
src/mol-plugin/behavior/dynamic/camera.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
+ * @author Jason Pattle <jpattle.exscientia.co.uk>
  */
 
 import { Loci } from '../../../mol-model/loci';
@@ -17,8 +18,25 @@ import { Vec3 } from '../../../mol-math/linear-algebra';
 const B = ButtonsType;
 const M = ModifiersKeys;
 const Trigger = Binding.Trigger;
-
-const DefaultFocusLociBindings = {
+const Key = Binding.TriggerKey;
+
+export const DefaultClickResetCameraOnEmpty = Binding([
+    Trigger(B.Flag.Primary, M.create()),
+    Trigger(B.Flag.Secondary, M.create()),
+    Trigger(B.Flag.Primary, M.create({ control: true }))
+], 'Reset camera focus', 'Click on nothing using ${triggers}');
+export const DefaultClickResetCameraOnEmptySelectMode = Binding([
+    Trigger(B.Flag.Secondary, M.create()),
+    Trigger(B.Flag.Primary, M.create({ control: true }))
+], 'Reset camera focus', 'Click on nothing using ${triggers}');
+
+type FocusLociBindings = {
+    clickCenterFocus: Binding
+    clickCenterFocusSelectMode: Binding
+    clickResetCameraOnEmpty?: Binding
+    clickResetCameraOnEmptySelectMode?: Binding
+}
+export const DefaultFocusLociBindings: FocusLociBindings = {
     clickCenterFocus: Binding([
         Trigger(B.Flag.Primary, M.create()),
         Trigger(B.Flag.Secondary, M.create()),
@@ -28,6 +46,8 @@ const DefaultFocusLociBindings = {
         Trigger(B.Flag.Secondary, M.create()),
         Trigger(B.Flag.Primary, M.create({ control: true }))
     ], 'Camera center and focus', 'Click element using ${triggers}'),
+    clickResetCameraOnEmpty: DefaultClickResetCameraOnEmpty,
+    clickResetCameraOnEmptySelectMode: DefaultClickResetCameraOnEmptySelectMode,
 };
 const FocusLociParams = {
     minRadius: PD.Numeric(8, { min: 1, max: 50, step: 1 }),
@@ -50,12 +70,16 @@ export const FocusLoci = PluginBehavior.create<FocusLociProps>({
                     ? this.params.bindings.clickCenterFocusSelectMode
                     : this.params.bindings.clickCenterFocus;
 
-                if (Binding.match(binding, button, modifiers)) {
-                    if (Loci.isEmpty(current.loci)) {
-                        PluginCommands.Camera.Reset(this.ctx, { });
-                        return;
-                    }
+                const resetBinding = this.ctx.selectionMode
+                    ? (this.params.bindings.clickResetCameraOnEmptySelectMode ?? DefaultClickResetCameraOnEmptySelectMode)
+                    : (this.params.bindings.clickResetCameraOnEmpty ?? DefaultClickResetCameraOnEmpty);
 
+                if (Loci.isEmpty(current.loci) && Binding.match(resetBinding, button, modifiers)) {
+                    PluginCommands.Camera.Reset(this.ctx, { });
+                    return;
+                }
+
+                if (Binding.match(binding, button, modifiers)) {
                     const loci = Loci.normalize(current.loci, this.ctx.managers.interactivity.props.granularity);
                     this.ctx.managers.camera.focusLoci(loci, this.params);
                 }
@@ -127,4 +151,79 @@ export const CameraAxisHelper = PluginBehavior.create<{}>({
     },
     params: () => ({}),
     display: { name: 'Camera Axis Helper' }
+});
+
+const DefaultCameraControlsBindings = {
+    keySpinAnimation: Binding([Key('KeyI')], 'Spin Animation', 'Press ${triggers}'),
+    keyRockAnimation: Binding([Key('KeyO')], 'Rock Animation', 'Press ${triggers}'),
+    keyToggleFlyMode: Binding([Key('Space', M.create({ shift: true }))], 'Toggle Fly Mode', 'Press ${triggers}'),
+    keyResetView: Binding([Key('KeyT')], 'Reset View', 'Press ${triggers}'),
+};
+const CameraControlsParams = {
+    bindings: PD.Value(DefaultCameraControlsBindings, { isHidden: true }),
+};
+type CameraControlsProps = PD.Values<typeof CameraControlsParams>
+
+export const CameraControls = PluginBehavior.create<CameraControlsProps>({
+    name: 'camera-controls',
+    category: 'interaction',
+    ctor: class extends PluginBehavior.Handler<CameraControlsProps> {
+        register(): void {
+            this.subscribeObservable(this.ctx.behaviors.interaction.key, ({ code, modifiers }) => {
+                if (!this.ctx.canvas3d) return;
+
+                // include defaults for backwards state compatibility
+                const b = { ...DefaultCameraControlsBindings, ...this.params.bindings };
+                const p = this.ctx.canvas3d.props.trackball;
+
+                if (Binding.matchKey(b.keySpinAnimation, code, modifiers)) {
+                    const name = p.animate.name !== 'spin' ? 'spin' : 'off';
+                    if (name === 'off') {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: { name, params: {} } }
+                        });
+                    } else {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: {
+                                name, params: { speed: 1 } }
+                            }
+                        });
+                    }
+                }
+
+                if (Binding.matchKey(b.keyRockAnimation, code, modifiers)) {
+                    const name = p.animate.name !== 'rock' ? 'rock' : 'off';
+                    if (name === 'off') {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: { name, params: {} } }
+                        });
+                    } else {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: {
+                                name, params: { speed: 0.3, angle: 10 } }
+                            }
+                        });
+                    }
+                }
+
+                if (Binding.matchKey(b.keyToggleFlyMode, code, modifiers)) {
+                    const flyMode = !p.flyMode;
+
+                    this.ctx.canvas3d.setProps({
+                        trackball: { flyMode }
+                    });
+
+                    if (this.ctx.canvas3dContext) {
+                        this.ctx.canvas3dContext.canvas.style.cursor = flyMode ? 'crosshair' : 'unset';
+                    }
+                }
+
+                if (Binding.matchKey(b.keyResetView, code, modifiers)) {
+                    PluginCommands.Camera.Reset(this.ctx, {});
+                }
+            });
+        }
+    },
+    params: () => CameraControlsParams,
+    display: { name: 'Camera Controls on Canvas' }
 });

+ 6 - 5
src/mol-plugin/behavior/dynamic/representation.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
+ * @author Jason Pattle <jpattle.exscientia.co.uk>
  */
 
 import { MarkerAction } from '../../../mol-util/marker-action';
@@ -92,11 +93,11 @@ export const HighlightLoci = PluginBehavior.create({
 
 //
 
-const DefaultSelectLociBindings = {
+export const DefaultSelectLociBindings = {
     clickSelect: Binding.Empty,
-    clickToggleExtend: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Toggle extended selection', '${triggers} to extend selection along polymer'),
+    clickToggleExtend: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Toggle extended selection', 'Click on element using ${triggers} to extend selection along polymer'),
     clickSelectOnly: Binding.Empty,
-    clickToggle: Binding([Trigger(B.Flag.Primary, M.create())], 'Toggle selection', '${triggers} on element'),
+    clickToggle: Binding([Trigger(B.Flag.Primary, M.create())], 'Toggle selection', 'Click on element using ${triggers}'),
     clickDeselect: Binding.Empty,
     clickDeselectAllOnEmpty: Binding([Trigger(B.Flag.Primary, M.create())], 'Deselect all', 'Click on nothing using ${triggers}'),
 };
@@ -236,7 +237,7 @@ export const DefaultLociLabelProvider = PluginBehavior.create({
 
 //
 
-const DefaultFocusLociBindings = {
+export const DefaultFocusLociBindings = {
     clickFocus: Binding([
         Trigger(B.Flag.Primary, M.create()),
     ], 'Representation Focus', 'Click element using ${triggers}'),

+ 17 - 2
src/mol-plugin/behavior/static/camera.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginContext } from '../../../mol-plugin/context';
@@ -11,6 +12,8 @@ export function registerDefault(ctx: PluginContext) {
     Reset(ctx);
     Focus(ctx);
     SetSnapshot(ctx);
+    OrientAxes(ctx);
+    ResetAxes(ctx);
 }
 
 export function Reset(ctx: PluginContext) {
@@ -30,4 +33,16 @@ export function Focus(ctx: PluginContext) {
         ctx.managers.camera.focusSphere({ center, radius }, { durationMs });
         ctx.events.canvas3d.settingsUpdated.next(void 0);
     });
-}
+}
+
+export function OrientAxes(ctx: PluginContext) {
+    PluginCommands.Camera.OrientAxes.subscribe(ctx, ({ structures, durationMs }) => {
+        ctx.managers.camera.orientAxes(structures, durationMs);
+    });
+}
+
+export function ResetAxes(ctx: PluginContext) {
+    PluginCommands.Camera.ResetAxes.subscribe(ctx, ({ durationMs }) => {
+        ctx.managers.camera.resetAxes(durationMs);
+    });
+}

+ 6 - 3
src/mol-plugin/commands.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { Camera } from '../mol-canvas3d/camera';
@@ -10,7 +11,7 @@ import { PluginCommand } from './command';
 import { StateTransform, State, StateAction } from '../mol-state';
 import { Canvas3DProps } from '../mol-canvas3d/canvas3d';
 import { PluginLayoutStateProps } from './layout';
-import { StructureElement } from '../mol-model/structure';
+import { Structure, StructureElement } from '../mol-model/structure';
 import { PluginState } from './state';
 import { PluginToast } from './util/toast';
 import { Vec3 } from '../mol-math/linear-algebra';
@@ -62,7 +63,9 @@ export const PluginCommands = {
     Camera: {
         Reset: PluginCommand<{ durationMs?: number, snapshot?: Partial<Camera.Snapshot> }>(),
         SetSnapshot: PluginCommand<{ snapshot: Partial<Camera.Snapshot>, durationMs?: number }>(),
-        Focus: PluginCommand<{ center: Vec3, radius: number, durationMs?: number }>()
+        Focus: PluginCommand<{ center: Vec3, radius: number, durationMs?: number }>(),
+        OrientAxes: PluginCommand<{ structures?: Structure[], durationMs?: number }>(),
+        ResetAxes: PluginCommand<{ durationMs?: number }>(),
     },
     Canvas3D: {
         SetSettings: PluginCommand<{ settings: Partial<Canvas3DProps> | ((old: Canvas3DProps) => Partial<Canvas3DProps> | void) }>(),

+ 7 - 5
src/mol-plugin/context.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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 produce, { setAutoFreeze } from 'immer';
 import { List } from 'immutable';
 import { merge, Subscription } from 'rxjs';
-import { filter, take } from 'rxjs/operators';
+import { debounceTime, filter, take, throttleTime } from 'rxjs/operators';
 import { Canvas3D, Canvas3DContext, DefaultCanvas3DParams } from '../mol-canvas3d/canvas3d';
 import { resizeCanvas } from '../mol-canvas3d/util';
 import { Vec2 } from '../mol-math/linear-algebra';
@@ -43,7 +43,7 @@ import { AssetManager } from '../mol-util/assets';
 import { Color } from '../mol-util/color';
 import { ajaxGet } from '../mol-util/data-source';
 import { isDebugMode, isProductionMode } from '../mol-util/debug';
-import { ModifiersKeys } from '../mol-util/input/input-observer';
+import { EmptyKeyInput, KeyInput, ModifiersKeys } from '../mol-util/input/input-observer';
 import { LogEntry } from '../mol-util/log-entry';
 import { objectForEach } from '../mol-util/object';
 import { RxEventHelper } from '../mol-util/rx-event-helper';
@@ -95,7 +95,8 @@ export class PluginContext {
             hover: this.ev.behavior<InteractivityManager.HoverEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0, button: 0 }),
             click: this.ev.behavior<InteractivityManager.ClickEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0, button: 0 }),
             drag: this.ev.behavior<InteractivityManager.DragEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0, button: 0, pageStart: Vec2(), pageEnd: Vec2() }),
-            selectionMode: this.ev.behavior<boolean>(false)
+            key: this.ev.behavior<KeyInput>(EmptyKeyInput),
+            selectionMode: this.ev.behavior<boolean>(false),
         },
         labels: {
             highlight: this.ev.behavior<{ labels: ReadonlyArray<LociLabel> }>({ labels: [] })
@@ -292,7 +293,8 @@ export class PluginContext {
             this.subs.push(this.canvas3d!.interaction.click.subscribe(e => this.behaviors.interaction.click.next(e)));
             this.subs.push(this.canvas3d!.interaction.drag.subscribe(e => this.behaviors.interaction.drag.next(e)));
             this.subs.push(this.canvas3d!.interaction.hover.subscribe(e => this.behaviors.interaction.hover.next(e)));
-            this.subs.push(this.canvas3d!.input.resize.subscribe(() => this.handleResize()));
+            this.subs.push(this.canvas3d!.input.resize.pipe(debounceTime(50), throttleTime(100, undefined, { leading: false, trailing: true })).subscribe(() => this.handleResize()));
+            this.subs.push(this.canvas3d!.input.keyDown.subscribe(e => this.behaviors.interaction.key.next(e)));
             this.subs.push(this.layout.events.updated.subscribe(() => requestAnimationFrame(() => this.handleResize())));
 
             this.handleResize();

+ 28 - 6
src/mol-plugin/headless-plugin-context.ts

@@ -5,33 +5,55 @@
  */
 
 import fs from 'fs';
+import { type PNG } from 'pngjs'; // Only import type here, the actual import must be provided by the caller
+import { type BufferRet as JpegBufferRet } from 'jpeg-js'; // Only import type here, the actual import must be provided by the caller
+
 import { Canvas3D } from '../mol-canvas3d/canvas3d';
 import { PostprocessingProps } from '../mol-canvas3d/passes/postprocessing';
 import { PluginContext } from './context';
 import { PluginSpec } from './spec';
-import { HeadlessScreenshotHelper, HeadlessScreenshotHelperOptions } from './util/headless-screenshot';
+import { HeadlessScreenshotHelper, HeadlessScreenshotHelperOptions, ExternalModules, RawImageData } from './util/headless-screenshot';
 
 
 /** PluginContext that can be used in Node.js (without DOM) */
 export class HeadlessPluginContext extends PluginContext {
     renderer: HeadlessScreenshotHelper;
 
-    constructor(spec: PluginSpec, canvasSize: { width: number, height: number } = { width: 640, height: 480 }, rendererOptions?: HeadlessScreenshotHelperOptions) {
+    /** External modules (`gl` and optionally `pngjs` and `jpeg-js`) must be provided to the constructor (this is to avoid Mol* being dependent on these packages which are only used here) */
+    constructor(externalModules: ExternalModules, spec: PluginSpec, canvasSize: { width: number, height: number } = { width: 640, height: 480 }, rendererOptions?: HeadlessScreenshotHelperOptions) {
         super(spec);
-        this.renderer = new HeadlessScreenshotHelper(canvasSize, undefined, rendererOptions);
+        this.renderer = new HeadlessScreenshotHelper(externalModules, canvasSize, undefined, rendererOptions);
         (this.canvas3d as Canvas3D) = this.renderer.canvas3d;
     }
 
-    /** Render the current plugin state to a PNG or JPEG file */
+    /** Render the current plugin state and save to a PNG or JPEG file */
     async saveImage(outPath: string, imageSize?: { width: number, height: number }, props?: Partial<PostprocessingProps>, format?: 'png' | 'jpeg', jpegQuality = 90) {
         this.canvas3d!.commit(true);
         return await this.renderer.saveImage(outPath, imageSize, props, format, jpegQuality);
     }
 
+    /** Render the current plugin state and return as raw image data */
+    async getImageRaw(imageSize?: { width: number, height: number }, props?: Partial<PostprocessingProps>): Promise<RawImageData> {
+        this.canvas3d!.commit(true);
+        return await this.renderer.getImageRaw(imageSize, props);
+    }
+
+    /** Render the current plugin state and return as a PNG object */
+    async getImagePng(imageSize?: { width: number, height: number }, props?: Partial<PostprocessingProps>): Promise<PNG> {
+        this.canvas3d!.commit(true);
+        return await this.renderer.getImagePng(imageSize, props);
+    }
+
+    /** Render the current plugin state and return as a JPEG object */
+    async getImageJpeg(imageSize?: { width: number, height: number }, props?: Partial<PostprocessingProps>, jpegQuality: number = 90): Promise<JpegBufferRet> {
+        this.canvas3d!.commit(true);
+        return await this.renderer.getImageJpeg(imageSize, props);
+    }
+
     /** Get the current plugin state */
-    getStateSnapshot() {
+    async getStateSnapshot() {
         this.canvas3d!.commit(true);
-        return this.managers.snapshot.getStateSnapshot({ params: {} });
+        return await this.managers.snapshot.getStateSnapshot({ params: {} });
     }
 
     /** Save the current plugin state to a MOLJ file */

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

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
@@ -120,6 +120,7 @@ export const DefaultPluginSpec = (): PluginSpec => ({
         PluginSpec.Behavior(PluginBehaviors.Representation.FocusLoci),
         PluginSpec.Behavior(PluginBehaviors.Camera.FocusLoci),
         PluginSpec.Behavior(PluginBehaviors.Camera.CameraAxisHelper),
+        PluginSpec.Behavior(PluginBehaviors.Camera.CameraControls),
         PluginSpec.Behavior(StructureFocusRepresentation),
 
         PluginSpec.Behavior(PluginBehaviors.CustomProps.StructureInfo),

+ 1 - 1
src/mol-plugin/state.ts

@@ -76,7 +76,7 @@ class PluginState extends PluginComponent {
         await this.animation.stop();
 
         // this needs to go 1st since these changes are already baked into the behavior and data state
-        if (snapshot.structureComponentManager?.options) await this.plugin.managers.structure.component.setOptions(snapshot.structureComponentManager?.options);
+        if (snapshot.structureComponentManager?.options) this.plugin.managers.structure.component._setSnapshotState(snapshot.structureComponentManager?.options);
         if (snapshot.behaviour) await this.plugin.runTask(this.behaviors.setSnapshot(snapshot.behaviour));
         if (snapshot.data) await this.plugin.runTask(this.data.setSnapshot(snapshot.data));
         if (snapshot.canvas3d?.props) {

+ 18 - 12
src/mol-plugin/util/headless-screenshot.ts

@@ -10,8 +10,8 @@
 
 import fs from 'fs';
 import path from 'path';
-import { type BufferRet as JpegBufferRet } from 'jpeg-js'; // Only import type here, the actual import is done by LazyImports
-import { type PNG } from 'pngjs'; // Only import type here, the actual import is done by LazyImports
+import { type BufferRet as JpegBufferRet } from 'jpeg-js'; // Only import type here, the actual import must be provided by the caller
+import { type PNG } from 'pngjs'; // Only import type here, the actual import must be provided by the caller
 
 import { Canvas3D, Canvas3DContext, Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
 import { ImagePass, ImageProps } from '../../mol-canvas3d/passes/image';
@@ -22,16 +22,14 @@ import { AssetManager } from '../../mol-util/assets';
 import { ColorNames } from '../../mol-util/color/names';
 import { PixelData } from '../../mol-util/image';
 import { InputObserver } from '../../mol-util/input/input-observer';
-import { LazyImports } from '../../mol-util/lazy-imports';
 import { ParamDefinition } from '../../mol-util/param-definition';
 
 
-const lazyImports = LazyImports.create('gl', 'jpeg-js', 'pngjs') as {
+export interface ExternalModules {
     'gl': typeof import('gl'),
-    'jpeg-js': typeof import('jpeg-js'),
-    'pngjs': typeof import('pngjs'),
-};
-
+    'jpeg-js'?: typeof import('jpeg-js'),
+    'pngjs'?: typeof import('pngjs'),
+}
 
 export type HeadlessScreenshotHelperOptions = {
     webgl?: WebGLContextAttributes,
@@ -51,11 +49,11 @@ export class HeadlessScreenshotHelper {
     readonly canvas3d: Canvas3D;
     readonly imagePass: ImagePass;
 
-    constructor(readonly canvasSize: { width: number, height: number }, canvas3d?: Canvas3D, options?: HeadlessScreenshotHelperOptions) {
+    constructor(readonly externalModules: ExternalModules, readonly canvasSize: { width: number, height: number }, canvas3d?: Canvas3D, options?: HeadlessScreenshotHelperOptions) {
         if (canvas3d) {
             this.canvas3d = canvas3d;
         } else {
-            const glContext = lazyImports.gl(this.canvasSize.width, this.canvasSize.height, options?.webgl ?? defaultWebGLAttributes());
+            const glContext = this.externalModules.gl(this.canvasSize.width, this.canvasSize.height, options?.webgl ?? defaultWebGLAttributes());
             const webgl = createContext(glContext);
             const input = InputObserver.create();
             const attribs = { ...Canvas3DContext.DefaultAttribs };
@@ -93,14 +91,20 @@ export class HeadlessScreenshotHelper {
 
     async getImagePng(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<PNG> {
         const imageData = await this.getImageRaw(imageSize, postprocessing);
-        const generatedPng = new lazyImports.pngjs.PNG({ width: imageData.width, height: imageData.height });
+        if (!this.externalModules.pngjs) {
+            throw new Error("External module 'pngjs' was not provided. If you want to use getImagePng, you must import 'pngjs' and provide it to the HeadlessPluginContext/HeadlessScreenshotHelper constructor.");
+        }
+        const generatedPng = new this.externalModules.pngjs.PNG({ width: imageData.width, height: imageData.height });
         generatedPng.data = Buffer.from(imageData.data.buffer);
         return generatedPng;
     }
 
     async getImageJpeg(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, jpegQuality: number = 90): Promise<JpegBufferRet> {
         const imageData = await this.getImageRaw(imageSize, postprocessing);
-        const generatedJpeg = lazyImports['jpeg-js'].encode(imageData, jpegQuality);
+        if (!this.externalModules['jpeg-js']) {
+            throw new Error("External module 'jpeg-js' was not provided. If you want to use getImageJpeg, you must import 'jpeg-js' and provide it to the HeadlessPluginContext/HeadlessScreenshotHelper constructor.");
+        }
+        const generatedJpeg = this.externalModules['jpeg-js'].encode(imageData, jpegQuality);
         return generatedJpeg;
     }
 
@@ -206,10 +210,12 @@ export const STYLIZED_POSTPROCESSING: Partial<PostprocessingProps> = {
     occlusion: {
         name: 'on' as const, params: {
             samples: 32,
+            multiScale: { name: 'off', params: {} },
             radius: 5,
             bias: 0.8,
             blurKernelSize: 15,
             resolutionScale: 1,
+            color: ColorNames.black,
         }
     }, outline: {
         name: 'on' as const, params: {

+ 2 - 2
src/mol-plugin/util/viewport-screenshot.ts

@@ -119,7 +119,7 @@ class ViewportScreenshotHelper extends PluginComponent {
             postprocessing: {
                 ...c.props.postprocessing,
                 occlusion: aoProps.name === 'on'
-                    ? { name: 'on', params: { ...aoProps.params, samples: 128, resolutionScale: 1 } }
+                    ? { name: 'on', params: { ...aoProps.params, samples: 128, resolutionScale: c.webgl.pixelRatio } }
                     : aoProps
             },
             marking: { ...c.props.marking }
@@ -143,7 +143,7 @@ class ViewportScreenshotHelper extends PluginComponent {
                 postprocessing: {
                     ...c.props.postprocessing,
                     occlusion: aoProps.name === 'on'
-                        ? { name: 'on', params: { ...aoProps.params, samples: 128, resolutionScale: 1 } }
+                        ? { name: 'on', params: { ...aoProps.params, samples: 128, resolutionScale: c.webgl.pixelRatio } }
                         : aoProps
                 },
                 marking: { ...c.props.marking }

+ 14 - 1
src/mol-repr/representation.ts

@@ -18,7 +18,7 @@ import { Loci as ModelLoci, EmptyLoci, isEmptyLoci } from '../mol-model/loci';
 import { Overpaint } from '../mol-theme/overpaint';
 import { Transparency } from '../mol-theme/transparency';
 import { Mat4 } from '../mol-math/linear-algebra';
-import { getQualityProps } from './util';
+import { LocationCallback, getQualityProps } from './util';
 import { BaseGeometry } from '../mol-geo/geometry/base';
 import { Visual } from './visual';
 import { CustomProperty } from '../mol-model-props/common/custom-property';
@@ -162,6 +162,7 @@ interface Representation<D, P extends PD.Params = PD.Params, S extends Represent
     setTheme: (theme: Theme) => void
     getLoci: (pickingId: PickingId) => ModelLoci
     getAllLoci: () => ModelLoci[]
+    eachLocation: (cb: LocationCallback) => void
     mark: (loci: ModelLoci, action: MarkerAction) => boolean
     destroy: () => void
 }
@@ -250,6 +251,7 @@ namespace Representation {
         setTheme: () => {},
         getLoci: () => EmptyLoci,
         getAllLoci: () => [],
+        eachLocation: () => {},
         mark: () => false,
         destroy: () => {}
     };
@@ -370,6 +372,14 @@ namespace Representation {
                 }
                 return loci;
             },
+            eachLocation: (cb: LocationCallback) => {
+                const { visuals } = currentProps;
+                for (let i = 0, il = reprList.length; i < il; ++i) {
+                    if (!visuals || visuals.includes(reprMap[i])) {
+                        reprList[i].eachLocation(cb);
+                    }
+                }
+            },
             mark: (loci: ModelLoci, action: MarkerAction) => {
                 let marked = false;
                 for (let i = 0, il = reprList.length; i < il; ++i) {
@@ -436,6 +446,9 @@ namespace Representation {
                 // TODO
                 return [];
             },
+            eachLocation: () => {
+                // TODO
+            },
             mark: (loci: ModelLoci, action: MarkerAction) => {
                 // TODO
                 return false;

+ 9 - 2
src/mol-repr/shape/representation.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -11,7 +11,7 @@ import { Subject } from 'rxjs';
 import { getNextMaterialId, createRenderObject, GraphicsRenderObject } from '../../mol-gl/render-object';
 import { Theme } from '../../mol-theme/theme';
 import { LocationIterator } from '../../mol-geo/util/location-iterator';
-import { VisualUpdateState } from '../util';
+import { LocationCallback, VisualUpdateState } from '../util';
 import { createMarkers } from '../../mol-geo/geometry/marker-data';
 import { MarkerAction, MarkerActions } from '../../mol-util/marker-action';
 import { ValueCell } from '../../mol-util';
@@ -223,6 +223,13 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
         getAllLoci() {
             return [Shape.Loci(_shape)];
         },
+        eachLocation: (cb: LocationCallback) => {
+            locationIt.reset();
+            while (locationIt.hasNext) {
+                const { location, isSecondary } = locationIt.move();
+                cb(location, isSecondary);
+            }
+        },
         mark(loci: Loci, action: MarkerAction) {
             if (!MarkerActions.is(_state.markerActions, action)) return false;
             if (ShapeGroup.isLoci(loci) || Shape.isLoci(loci)) {

+ 8 - 2
src/mol-repr/structure/complex-representation.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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>
@@ -22,6 +22,7 @@ import { Clipping } from '../../mol-theme/clipping';
 import { Transparency } from '../../mol-theme/transparency';
 import { WebGLContext } from '../../mol-gl/webgl/context';
 import { Substance } from '../../mol-theme/substance';
+import { LocationCallback } from '../util';
 
 export function ComplexRepresentation<P extends StructureParams>(label: string, ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, P>, visualCtor: (materialId: number, structure: Structure, props: PD.Values<P>, webgl?: WebGLContext) => ComplexVisual<P>): StructureRepresentation<P> {
     let version = 0;
@@ -77,7 +78,11 @@ export function ComplexRepresentation<P extends StructureParams>(label: string,
     }
 
     function getAllLoci() {
-        return [Structure.Loci(_structure.target)];
+        return [Structure.Loci(_structure.child ?? _structure)];
+    }
+
+    function eachLocation(cb: LocationCallback) {
+        visual?.eachLocation(cb);
     }
 
     function mark(loci: Loci, action: MarkerAction) {
@@ -162,6 +167,7 @@ export function ComplexRepresentation<P extends StructureParams>(label: string,
         setTheme,
         getLoci,
         getAllLoci,
+        eachLocation,
         mark,
         destroy
     };

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