ソースを参照

Merge branch 'master' of https://github.com/molstar/molstar into pr/yakomaxa/514

Alexander Rose 2 年 前
コミット
d08776bf19
97 ファイル変更2260 行追加986 行削除
  1. 47 4
      CHANGELOG.md
  2. 2 2
      README.md
  3. 302 232
      package-lock.json
  4. 26 23
      package.json
  5. 1 1
      src/apps/docking-viewer/index.ts
  6. 3 3
      src/apps/docking-viewer/viewport.tsx
  7. 4 1
      src/apps/viewer/app.ts
  8. 9 0
      src/apps/viewer/embedded.html
  9. BIN
      src/extensions/backgrounds/images/cells.jpg
  10. 90 0
      src/extensions/backgrounds/index.ts
  11. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg
  12. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg
  13. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg
  14. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg
  15. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg
  16. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg
  17. 10 0
      src/extensions/backgrounds/typings.d.ts
  18. 25 17
      src/extensions/dnatco/confal-pyramids/property.ts
  19. 74 54
      src/extensions/dnatco/confal-pyramids/representation.ts
  20. 7 6
      src/extensions/dnatco/confal-pyramids/types.ts
  21. 87 247
      src/extensions/dnatco/confal-pyramids/util.ts
  22. 1 0
      src/extensions/mp4-export/encoder.ts
  23. 1 4
      src/extensions/rcsb/graphql/types.ts
  24. 43 10
      src/mol-canvas3d/canvas3d.ts
  25. 461 0
      src/mol-canvas3d/passes/background.ts
  26. 49 45
      src/mol-canvas3d/passes/draw.ts
  27. 2 2
      src/mol-canvas3d/passes/fxaa.ts
  28. 12 3
      src/mol-canvas3d/passes/image.ts
  29. 4 4
      src/mol-canvas3d/passes/marking.ts
  30. 10 10
      src/mol-canvas3d/passes/multi-sample.ts
  31. 3 2
      src/mol-canvas3d/passes/passes.ts
  32. 54 24
      src/mol-canvas3d/passes/postprocessing.ts
  33. 2 2
      src/mol-canvas3d/passes/smaa.ts
  34. 11 1
      src/mol-canvas3d/passes/wboit.ts
  35. 6 6
      src/mol-geo/geometry/texture-mesh/color-smoothing.ts
  36. 2 2
      src/mol-gl/compute/grid3d.ts
  37. 7 7
      src/mol-gl/compute/histogram-pyramid/reduction.ts
  38. 2 2
      src/mol-gl/compute/histogram-pyramid/sum.ts
  39. 4 4
      src/mol-gl/compute/marching-cubes/active-voxels.ts
  40. 2 2
      src/mol-gl/compute/marching-cubes/isosurface.ts
  41. 2 2
      src/mol-gl/compute/util.ts
  42. 13 9
      src/mol-gl/renderer.ts
  43. 30 2
      src/mol-gl/scene.ts
  44. 2 0
      src/mol-gl/shader-code.ts
  45. 85 0
      src/mol-gl/shader/background.frag.ts
  46. 12 0
      src/mol-gl/shader/background.vert.ts
  47. 0 1
      src/mol-gl/shader/chunks/color-frag-params.glsl.ts
  48. 0 1
      src/mol-gl/shader/chunks/color-vert-params.glsl.ts
  49. 0 2
      src/mol-gl/shader/chunks/common-frag-params.glsl.ts
  50. 3 3
      src/mol-gl/shader/chunks/common-vert-params.glsl.ts
  51. 5 5
      src/mol-gl/shader/cylinders.frag.ts
  52. 7 7
      src/mol-gl/shader/spheres.frag.ts
  53. 6 5
      src/mol-gl/webgl/context.ts
  54. 3 3
      src/mol-gl/webgl/render-item.ts
  55. 10 2
      src/mol-gl/webgl/resources.ts
  56. 29 0
      src/mol-gl/webgl/state.ts
  57. 119 1
      src/mol-gl/webgl/texture.ts
  58. 1 1
      src/mol-io/reader/cif/schema/bird.ts
  59. 1 1
      src/mol-io/reader/cif/schema/ccd.ts
  60. 1 1
      src/mol-io/reader/cif/schema/mmcif.ts
  61. 6 6
      src/mol-math/geometry/gaussian-density/gpu.ts
  62. 11 3
      src/mol-math/geometry/primitives/box3d.ts
  63. 1 1
      src/mol-math/linear-algebra/3d/vec3.ts
  64. 4 1
      src/mol-model-formats/structure/mol.ts
  65. 4 1
      src/mol-model-formats/structure/mol2.ts
  66. 1 1
      src/mol-model-props/common/custom-element-property.ts
  67. 48 14
      src/mol-model-props/computed/representations/interactions-inter-unit-cylinder.ts
  68. 31 10
      src/mol-model-props/computed/representations/interactions-intra-unit-cylinder.ts
  69. 16 0
      src/mol-model-props/computed/representations/shared.ts
  70. 0 10
      src/mol-model/structure/structure/carbohydrates/constants.ts
  71. 32 4
      src/mol-model/structure/structure/structure.ts
  72. 14 6
      src/mol-model/structure/structure/unit/bonds/inter-compute.ts
  73. 4 1
      src/mol-model/structure/structure/unit/bonds/intra-compute.ts
  74. 21 5
      src/mol-model/structure/structure/util/superposition-sifts-mapping.ts
  75. 13 7
      src/mol-plugin-state/actions/file.ts
  76. 2 1
      src/mol-plugin-state/actions/structure.ts
  77. 25 21
      src/mol-plugin-state/builder/structure/representation-preset.ts
  78. 28 7
      src/mol-plugin-ui/custom/volume.tsx
  79. 21 9
      src/mol-plugin-ui/viewport/help.tsx
  80. 14 2
      src/mol-plugin-ui/viewport/simple-settings.tsx
  81. 14 3
      src/mol-plugin/behavior/behavior.ts
  82. 163 84
      src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
  83. 3 1
      src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
  84. 6 2
      src/mol-plugin/config.ts
  85. 1 1
      src/mol-plugin/context.ts
  86. 3 8
      src/mol-plugin/features.ts
  87. 3 1
      src/mol-plugin/util/viewport-screenshot.ts
  88. 4 0
      src/mol-util/binding.ts
  89. 41 0
      src/mol-util/single-async-queue.ts
  90. 4 1
      src/tests/browser/marching-cubes.ts
  91. 4 1
      src/tests/browser/render-lines.ts
  92. 4 1
      src/tests/browser/render-mesh.ts
  93. 4 1
      src/tests/browser/render-shape.ts
  94. 4 1
      src/tests/browser/render-spheres.ts
  95. 4 2
      src/tests/browser/render-structure.ts
  96. 4 1
      src/tests/browser/render-text.ts
  97. 6 2
      webpack.config.common.js

+ 47 - 4
CHANGELOG.md

@@ -6,12 +6,55 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
-- Fix defaultAttribs handling in Canvas3DContext.fromCanvas
-- Add custom labels to Confal pyramids
-- Improve naming of some internal types in Confal pyramids extension coordinate
-- Add example mmCIF file with categories necessary to display Confal pyramids
+- [Fix] Clone ``Canvas3DParams`` when creating a ``Canvas3D`` instance to prevent shared state between multiple instances
+- Add ``includeResidueTest`` option to ``alignAndSuperposeWithSIFTSMapping``
+- Add ``parentDisplay`` param for interactions representation.
 - [Experimental] Add support for PyMOL, VMD, and Jmol atom expressions in selection scripts
 
+## [v3.16.0] - 2022-08-25
+
+- Support ``globalColorParams`` and ``globalSymmetryParams`` in common representation params
+- Support ``label`` parameter in ``Viewer.loadStructureFromUrl``
+- Fix ``ViewportHelpContent`` Mouse Controls section
+
+## [v3.15.0] - 2022-08-23
+
+- Fix wboit in Safari >=15 (add missing depth renderbuffer to wboit pass)
+- Add 'Around Camera' option to Volume streaming
+- Avoid queuing more than one update in Volume streaming
+
+## [v3.14.0] - 2022-08-20
+
+- Expose inter-bonds compute params in structure
+- Improve performance of inter/intra-bonds compute
+- Fix defaultAttribs handling in Canvas3DContext.fromCanvas
+- Confal pyramids extension improvements
+    - Add custom labels to Confal pyramids
+    - Improve naming of some internal types in Confal pyramids extension coordinate
+    - Add example mmCIF file with categories necessary to display Confal pyramids
+    - Change the lookup logic of NtC steps from residues
+- Add support for download of gzipped files
+- Don't filter IndexPairBonds by element-based rules in MOL/SDF and MOL2 (without symmetry) models
+- Fix Glycam Saccharide Names used by default
+- Fix GPU surfaces rendering in Safari with WebGL2
+- Add ``fov`` (Field of View) Canvas3D parameter
+- Add ``sceneRadiusFactor`` Canvas3D parameter
+- Add background pass (skybox, image, horizontal/radial gradient)
+    - Set simple-settings presets via ``PluginConfig.Background.Styles``
+    - Example presets in new backgrounds extension
+    - Load skybox/image from URL or File (saved in session)
+    - Opacity, saturation, lightness controls for skybox/image
+    - Coverage (viewport or canvas) controls for image/gradient
+- [Breaking] ``AssetManager`` needs to be passed to various graphics related classes
+- Fix SSAO renderable initialization
+- Reduce number of webgl state changes
+    - Add ``viewport`` and ``scissor`` to state object
+    - Add ``hasOpaque`` to scene object
+- Handle edge cases where some renderables would not get (correctly) rendered
+    - Fix text background rendering for opaque text
+    - Fix helper scenes not shown when rendering directly to draw target
+- Fix ``CustomElementProperty`` coloring not working
+
 ## [v3.13.0] - 2022-07-24
 
 - Fix: only update camera state if manualReset is off (#494)

+ 2 - 2
README.md

@@ -126,7 +126,7 @@ and navigate to `build/viewer`
 
 **GraphQL schemas**
 
-    node node_modules//@graphql-codegen/cli/bin -c src/extensions/rcsb/graphql/codegen.yml
+    node node_modules/@graphql-codegen/cli/cjs/bin -c src/extensions/rcsb/graphql/codegen.yml
 
 ### Other scripts
 **Create chem comp bond table**
@@ -152,7 +152,7 @@ Or
     node lib/commonjs/cli/cif2bcif
 
 E.g.
- 
+
     node lib/commonjs/cli/cif2bcif src.cif out.bcif.gz
     node lib/commonjs/cli/cif2bcif src.bcif.gz out.cif
 

ファイルの差分が大きいため隠しています
+ 302 - 232
package-lock.json


+ 26 - 23
package.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "3.13.0",
+  "version": "3.16.0",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -20,7 +20,7 @@
     "rebuild": "npm run clean && npm run build",
     "build-viewer": "npm run build-tsc && npm run build-extra && npm run build-webpack-viewer",
     "build-tsc": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\"",
-    "build-extra": "cpx \"src/**/*.{scss,html,ico}\" lib/",
+    "build-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/",
     "build-webpack": "webpack --mode production --config ./webpack.config.production.js",
     "build-webpack-viewer": "webpack --mode production --config ./webpack.config.viewer.js",
     "watch": "concurrently -c \"green,green,gray,gray\" --names \"tsc,srv,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-servers\" \"npm:watch-extra\" \"npm:watch-webpack\"",
@@ -28,7 +28,7 @@
     "watch-viewer-debug": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer-debug\"",
     "watch-tsc": "tsc --watch --incremental",
     "watch-servers": "tsc --build tsconfig.commonjs.json --watch --incremental",
-    "watch-extra": "cpx \"src/**/*.{scss,html,ico}\" lib/ --watch",
+    "watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ --watch",
     "watch-webpack": "webpack -w --mode development --stats minimal",
     "watch-webpack-viewer": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.js",
     "watch-webpack-viewer-debug": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.debug.js",
@@ -75,7 +75,9 @@
       "node_modules",
       "lib"
     ],
-    "testURL": "http://localhost/",
+    "testEnvironmentOptions": {
+      "url": "http://localhost/"
+    },
     "testRegex": "\\.spec\\.ts$"
   },
   "author": "Mol* Contributors",
@@ -88,34 +90,35 @@
     "Michal Malý <michal.maly@ibt.cas.cz>",
     "Jiří Černý <jiri.cerny@ibt.cas.cz>",
     "Panagiotis Tourlas <panagiot_tourlov@hotmail.com>",
+    "Adam Midlik <midlik@gmail.com>",
     "Koya Sakuma <koya.sakuma.work@gmail.com>"
   ],
   "license": "MIT",
   "devDependencies": {
-    "@graphql-codegen/add": "^3.2.0",
-    "@graphql-codegen/cli": "^2.9.1",
-    "@graphql-codegen/time": "^3.2.0",
-    "@graphql-codegen/typescript": "^2.7.2",
-    "@graphql-codegen/typescript-graphql-files-modules": "^2.2.0",
-    "@graphql-codegen/typescript-graphql-request": "^4.5.2",
-    "@graphql-codegen/typescript-operations": "^2.5.2",
+    "@graphql-codegen/add": "^3.2.1",
+    "@graphql-codegen/cli": "^2.11.6",
+    "@graphql-codegen/time": "^3.2.1",
+    "@graphql-codegen/typescript": "^2.7.3",
+    "@graphql-codegen/typescript-graphql-files-modules": "^2.2.1",
+    "@graphql-codegen/typescript-graphql-request": "^4.5.3",
+    "@graphql-codegen/typescript-operations": "^2.5.3",
     "@types/cors": "^2.8.12",
     "@types/gl": "^4.1.1",
-    "@types/jest": "^28.1.6",
-    "@types/react": "^18.0.15",
+    "@types/jest": "^28.1.7",
+    "@types/react": "^18.0.17",
     "@types/react-dom": "^18.0.6",
-    "@typescript-eslint/eslint-plugin": "^5.30.7",
-    "@typescript-eslint/parser": "^5.30.7",
+    "@typescript-eslint/eslint-plugin": "^5.33.1",
+    "@typescript-eslint/parser": "^5.33.1",
     "benchmark": "^2.1.4",
     "concurrently": "^7.3.0",
     "cpx2": "^4.2.0",
     "crypto-browserify": "^3.12.0",
     "css-loader": "^6.7.1",
-    "eslint": "^8.20.0",
+    "eslint": "^8.22.0",
     "extra-watch-webpack-plugin": "^1.0.3",
     "file-loader": "^6.2.0",
     "fs-extra": "^10.1.0",
-    "graphql": "^16.5.0",
+    "graphql": "^16.6.0",
     "http-server": "^14.1.1",
     "jest": "^28.1.3",
     "mini-css-extract-plugin": "^2.6.1",
@@ -123,14 +126,14 @@
     "raw-loader": "^4.0.2",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "sass": "^1.54.0",
+    "sass": "^1.54.5",
     "sass-loader": "^13.0.2",
-    "simple-git": "^3.10.0",
+    "simple-git": "^3.12.0",
     "stream-browserify": "^3.0.0",
     "style-loader": "^3.3.1",
-    "ts-jest": "^28.0.7",
+    "ts-jest": "^28.0.8",
     "typescript": "^4.7.4",
-    "webpack": "^5.73.0",
+    "webpack": "^5.74.0",
     "webpack-cli": "^4.10.0"
   },
   "dependencies": {
@@ -138,7 +141,7 @@
     "@types/benchmark": "^2.1.1",
     "@types/compression": "1.7.2",
     "@types/express": "^4.17.13",
-    "@types/node": "^16.11.45",
+    "@types/node": "^16.11.51",
     "@types/node-fetch": "^2.6.2",
     "@types/swagger-ui-dist": "3.30.1",
     "argparse": "^2.0.1",
@@ -151,7 +154,7 @@
     "immutable": "^4.1.0",
     "node-fetch": "^2.6.7",
     "rxjs": "^7.5.6",
-    "swagger-ui-dist": "^4.13.0",
+    "swagger-ui-dist": "^4.14.0",
     "tslib": "^2.4.0",
     "util.promisify": "^1.1.1",
     "xhr2": "^0.2.1"

+ 1 - 1
src/apps/docking-viewer/index.ts

@@ -166,7 +166,7 @@ class Viewer {
             structures.push({ ref: structureProperties?.ref || structure.ref });
         }
 
-        // remove current structuresfrom hierarchy as they will be merged
+        // remove current structures from hierarchy as they will be merged
         // TODO only works with using loadStructuresFromUrlsAndMerge once
         //      need some more API metho to work with the hierarchy
         this.plugin.managers.structure.hierarchy.updateCurrent(this.plugin.managers.structure.hierarchy.current.structures, 'remove');

+ 3 - 3
src/apps/docking-viewer/viewport.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -202,14 +202,14 @@ const InteractionsPreset = StructureRepresentationPresetProvider({
         const components = {
             ligand: await presetStaticComponent(plugin, structureCell, 'ligand'),
             surroundings: await plugin.builders.structure.tryCreateComponentFromSelection(structureCell, ligandSurroundings, `surroundings`),
-            interactions: await plugin.builders.structure.tryCreateComponentFromSelection(structureCell, ligandPlusSurroundings, `interactions`)
+            interactions: await presetStaticComponent(plugin, structureCell, 'ligand'),
         };
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
             ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, material: CustomMaterial, sizeFactor: 0.3 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
             ballAndStick: builder.buildRepresentation(update, components.surroundings, { type: 'ball-and-stick', typeParams: { ...typeParams, material: CustomMaterial, sizeFactor: 0.1, sizeAspectRatio: 1 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ball-and-stick' }),
-            interactions: builder.buildRepresentation(update, components.interactions, { type: InteractionsRepresentationProvider, typeParams: { ...typeParams, material: CustomMaterial }, color: InteractionTypeColorThemeProvider }, { tag: 'interactions' }),
+            interactions: builder.buildRepresentation(update, components.interactions, { type: InteractionsRepresentationProvider, typeParams: { ...typeParams, material: CustomMaterial, includeParent: true, parentDisplay: 'between' }, color: InteractionTypeColorThemeProvider }, { tag: 'interactions' }),
             label: builder.buildRepresentation(update, components.surroundings, { type: 'label', typeParams: { ...typeParams, material: CustomMaterial, background: false, borderWidth: 0.1 }, color: 'uniform', colorParams: { value: Color(0x000000) } }, { tag: 'label' }),
         };
 

+ 4 - 1
src/apps/viewer/app.ts

@@ -46,6 +46,7 @@ import { Color } from '../../mol-util/color';
 import '../../mol-util/polyfill';
 import { ObjectKeys } from '../../mol-util/type-helpers';
 import { SaccharideCompIdMapType } from '../../mol-model/structure/structure/carbohydrates/constants';
+import { Backgrounds } from '../../extensions/backgrounds';
 
 export { PLUGIN_VERSION as version } from '../../mol-plugin/version';
 export { setDebugMode, setProductionMode, setTimingMode } from '../../mol-util/debug';
@@ -55,6 +56,7 @@ const CustomFormats = [
 ];
 
 const Extensions = {
+    'backgrounds': PluginSpec.Behavior(Backgrounds),
     'cellpack': PluginSpec.Behavior(CellPack),
     'dnatco-confal-pyramids': PluginSpec.Behavior(DnatcoConfalPyramids),
     'pdbe-structure-quality-report': PluginSpec.Behavior(PDBeStructureQualityReport),
@@ -197,7 +199,7 @@ export class Viewer {
         return PluginCommands.State.Snapshots.OpenUrl(this.plugin, { url, type });
     }
 
-    loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions) {
+    loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions & { label?: string }) {
         const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
         return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
             source: {
@@ -206,6 +208,7 @@ export class Viewer {
                     url: Asset.Url(url),
                     format: format as any,
                     isBinary,
+                    label: options?.label,
                     options: { ...params.source.params.options, representationParams: options?.representationParams as any },
                 }
             }

+ 9 - 0
src/apps/viewer/embedded.html

@@ -38,6 +38,15 @@
                 viewer.loadPdb('7bv2');
                 viewer.loadEmdb('EMD-30210', { detail: 6 });
                 // viewer.loadAllModelsOrAssemblyFromUrl('https://cs.litemol.org/5ire/full', 'mmcif', false, { representationParams: { theme: { globalName: 'operator-name' } } })
+                // viewer.loadStructureFromUrl('my url', 'pdb', false, {
+                //     representationParams: {
+                //         theme: {
+                //             globalName: 'uniform',
+                //             globalColorParams: { value: 0xff0000 }
+                //         }
+                //     },
+                //     label: 'my structure'
+                // });
             });
         </script>
     </body>

BIN
src/extensions/backgrounds/images/cells.jpg


+ 90 - 0
src/extensions/backgrounds/index.ts

@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { PluginBehavior } from '../../mol-plugin/behavior/behavior';
+import { PluginConfig } from '../../mol-plugin/config';
+import { Color } from '../../mol-util/color/color';
+
+// from https://visualsonline.cancer.gov/details.cfm?imageid=2304, public domain
+import image_cells from './images/cells.jpg';
+
+// created with http://alexcpeterson.com/spacescape/
+import face_nebula_nx from './skyboxes/nebula/nebula_left2.jpg';
+import face_nebula_ny from './skyboxes/nebula/nebula_bottom4.jpg';
+import face_nebula_nz from './skyboxes/nebula/nebula_back6.jpg';
+import face_nebula_px from './skyboxes/nebula/nebula_right1.jpg';
+import face_nebula_py from './skyboxes/nebula/nebula_top3.jpg';
+import face_nebula_pz from './skyboxes/nebula/nebula_front5.jpg';
+
+export const Backgrounds = PluginBehavior.create<{ }>({
+    name: 'extension-backgrounds',
+    category: 'misc',
+    display: {
+        name: 'Backgrounds'
+    },
+    ctor: class extends PluginBehavior.Handler<{ }> {
+        register(): void {
+            this.ctx.config.set(PluginConfig.Background.Styles, [
+                [{
+                    variant: {
+                        name: 'radialGradient',
+                        params: {
+                            centerColor: Color(0xFFFFFF),
+                            edgeColor: Color(0x808080),
+                            ratio: 0.2,
+                            coverage: 'viewport',
+                        }
+                    }
+                }, 'Light Radial Gradient'],
+                [{
+                    variant: {
+                        name: 'image',
+                        params: {
+                            source: {
+                                name: 'url',
+                                params: image_cells
+                            },
+                            lightness: 0,
+                            saturation: 0,
+                            opacity: 1,
+                            coverage: 'viewport',
+                        }
+                    }
+                }, 'Normal Cells Image'],
+                [{
+                    variant: {
+                        name: 'skybox',
+                        params: {
+                            faces: {
+                                name: 'urls',
+                                params: {
+                                    nx: face_nebula_nx,
+                                    ny: face_nebula_ny,
+                                    nz: face_nebula_nz,
+                                    px: face_nebula_px,
+                                    py: face_nebula_py,
+                                    pz: face_nebula_pz,
+                                }
+                            },
+                            lightness: 0,
+                            saturation: 0,
+                            opacity: 1,
+                        }
+                    }
+                }, 'Purple Nebula Skybox'],
+            ]);
+        }
+
+        update() {
+            return false;
+        }
+
+        unregister() {
+            this.ctx.config.set(PluginConfig.Background.Styles, []);
+        }
+    },
+    params: () => ({ })
+});

BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg


+ 10 - 0
src/extensions/backgrounds/typings.d.ts

@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+declare module '*.jpg' {
+    const value: string;
+    export = value;
+}

+ 25 - 17
src/extensions/dnatco/confal-pyramids/property.ts

@@ -16,7 +16,7 @@ import { PropertyWrapper } from '../../../mol-model-props/common/wrapper';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
 
-export type ConfalPyramids = PropertyWrapper<CPT.StepsData | undefined>;
+export type ConfalPyramids = PropertyWrapper<CPT.Steps | undefined>;
 
 export namespace ConfalPyramids {
     export const Schema = {
@@ -105,13 +105,13 @@ export const ConfalPyramidsProvider: CustomModelProperty.Provider<ConfalPyramids
 
 type StepsSummaryTable = Table<typeof ConfalPyramids.Schema.ndb_struct_ntc_step_summary>;
 
-function createPyramidsFromCif(model: Model,
+function createPyramidsFromCif(
+    model: Model,
     cifSteps: Table<typeof ConfalPyramids.Schema.ndb_struct_ntc_step>,
-    stepsSummary: StepsSummaryTable): CPT.StepsData {
+    stepsSummary: StepsSummaryTable
+): CPT.Steps {
     const steps = new Array<CPT.Step>();
-    const names = new Map<string, number>();
-    const halfPyramids = new Array<CPT.HalfPyramid>();
-    let hasMultipleModels = false;
+    const mapping = new Array<CPT.MappedChains>();
 
     const {
         id, PDB_model_number, name,
@@ -123,21 +123,24 @@ function createPyramidsFromCif(model: Model,
     if (_rowCount !== stepsSummary._rowCount) throw new Error('Inconsistent mmCIF data');
 
     for (let i = 0; i < _rowCount; i++) {
-        const model_num = PDB_model_number.value(i);
-        if (model_num !== model.modelNum)
-            hasMultipleModels = true;
-
         const {
             NtC,
             confal_score,
             rmsd
         } = getSummaryData(id.value(i), i, stepsSummary);
+        const modelNum = PDB_model_number.value(i);
+        const chainId = auth_asym_id_1.value(i);
+        const seqId = auth_seq_id_1.value(i);
+        const modelIdx = modelNum - 1;
+
+        if (mapping.length <= modelIdx || !mapping[modelIdx])
+            mapping[modelIdx] = new Map<string, CPT.MappedResidues>();
 
         const step = {
-            PDB_model_number: model_num,
+            PDB_model_number: modelNum,
             name: name.value(i),
-            auth_asym_id_1: auth_asym_id_1.value(i),
-            auth_seq_id_1: auth_seq_id_1.value(i),
+            auth_asym_id_1: chainId,
+            auth_seq_id_1: seqId,
             label_comp_id_1: label_comp_id_1.value(i),
             label_alt_id_1: label_alt_id_1.value(i),
             PDB_ins_code_1: PDB_ins_code_1.value(i),
@@ -152,13 +155,18 @@ function createPyramidsFromCif(model: Model,
         };
 
         steps.push(step);
-        names.set(step.name, steps.length - 1);
 
-        halfPyramids.push({ step, isLower: false });
-        halfPyramids.push({ step, isLower: true });
+        const mappedChains = mapping[modelIdx];
+        const residuesOnChain = mappedChains.get(chainId) ?? new Map<number, number[]>();
+        const stepsForResidue = residuesOnChain.get(seqId) ?? [];
+        stepsForResidue.push(steps.length - 1);
+
+        residuesOnChain.set(seqId, stepsForResidue);
+        mappedChains.set(chainId, residuesOnChain);
+        mapping[modelIdx] = mappedChains;
     }
 
-    return { steps, names, halfPyramids, hasMultipleModels };
+    return { steps, mapping };
 }
 
 function getSummaryData(id: number, i: number, stepsSummary: StepsSummaryTable) {

+ 74 - 54
src/extensions/dnatco/confal-pyramids/representation.ts

@@ -6,7 +6,7 @@
  */
 
 import { ConfalPyramids, ConfalPyramidsProvider } from './property';
-import { ConfalPyramidsUtil } from './util';
+import { ConfalPyramidsIterator } from './util';
 import { ConfalPyramidsTypes as CPT } from './types';
 import { Interval } from '../../../mol-data/int';
 import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
@@ -32,6 +32,12 @@ const t = Mat4.identity();
 const w = Vec3.zero();
 const mp = Vec3.zero();
 
+const posO3 = Vec3();
+const posP = Vec3();
+const posOP1 = Vec3();
+const posOP2 = Vec3();
+const posO5 = Vec3();
+
 function calcMidpoint(mp: Vec3, v: Vec3, w: Vec3) {
     Vec3.sub(mp, v, w);
     Vec3.scale(mp, mp, 0.5);
@@ -53,64 +59,76 @@ function createConfalPyramidsIterator(structureGroup: StructureGroup): LocationI
     const { structure, group } = structureGroup;
     const instanceCount = group.units.length;
 
-    const prop = ConfalPyramidsProvider.get(structure.model).value;
-    if (prop === undefined || prop.data === undefined) {
-        return LocationIterator(0, 1, 1, () => NullLocation);
-    }
+    const data = ConfalPyramidsProvider.get(structure.model)?.value?.data;
+    if (!data) return LocationIterator(0, 1, 1, () => NullLocation);
 
-    const { halfPyramids } = prop.data;
+    const halfPyramidsCount = data.steps.length * 2;
 
     const getLocation = (groupIndex: number, instanceIndex: number) => {
-        if (halfPyramids.length <= groupIndex) return NullLocation;
-        return CPT.Location(halfPyramids[groupIndex]);
+        if (halfPyramidsCount <= groupIndex) return NullLocation;
+        const idx = Math.floor(groupIndex / 2); // Map groupIndex to a step, see createConfalPyramidsMesh() for full explanation
+        return CPT.Location(data.steps[idx], groupIndex % 2 === 1);
     };
-    return LocationIterator(halfPyramids.length, instanceCount, 1, getLocation);
+    return LocationIterator(halfPyramidsCount, instanceCount, 1, getLocation);
 }
 
 function createConfalPyramidsMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: PD.Values<ConfalPyramidsMeshParams>, mesh?: Mesh) {
     if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
 
-    const prop = ConfalPyramidsProvider.get(structure.model).value;
-    if (prop === undefined || prop.data === undefined) return Mesh.createEmpty(mesh);
+    const data = ConfalPyramidsProvider.get(structure.model)?.value?.data;
+    if (!data) return Mesh.createEmpty(mesh);
 
-    const { steps } = prop.data;
+    const { steps, mapping } = data;
     if (steps.length === 0) return Mesh.createEmpty(mesh);
-
-    const mb = MeshBuilder.createState(512, 512, mesh);
-
-    const handler = (step: CPT.Step, first: ConfalPyramidsUtil.FirstResidueAtoms, second: ConfalPyramidsUtil.SecondResidueAtoms, firsLocIndex: number, secondLocIndex: number) => {
-        if (firsLocIndex === -1 || secondLocIndex === -1)
-            throw new Error('Invalid location index');
-
-        const scale = (step.confal_score - 20.0) / 100.0;
-        const O3 = first.O3.pos;
-        const OP1 = second.OP1.pos; const OP2 = second.OP2.pos; const O5 = second.O5.pos; const P = second.P.pos;
-
-        shiftVertex(O3, P, scale);
-        shiftVertex(OP1, P, scale);
-        shiftVertex(OP2, P, scale);
-        shiftVertex(O5, P, scale);
-        calcMidpoint(mp, O3, O5);
-
-        mb.currentGroup = firsLocIndex;
-        let pb = PrimitiveBuilder(3);
-        /* Upper part (for first residue in step) */
-        pb.add(O3, OP1, OP2);
-        pb.add(O3, mp, OP1);
-        pb.add(O3, OP2, mp);
-        MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
-
-        /* Lower part (for second residue in step */
-        mb.currentGroup = secondLocIndex;
-        pb = PrimitiveBuilder(3);
-        pb.add(mp, O5, OP1);
-        pb.add(mp, OP2, O5);
-        pb.add(O5, OP2, OP1);
-        MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
-    };
-
-    const walker = new ConfalPyramidsUtil.UnitWalker(structure, unit, handler);
-    walker.walk();
+    const vertexCount = (6 * steps.length) / mapping.length;
+
+    const mb = MeshBuilder.createState(vertexCount, vertexCount / 10, mesh);
+
+    const it = new ConfalPyramidsIterator(structure, unit);
+    while (it.hasNext) {
+        const allPoints = it.move();
+
+        for (const points of allPoints) {
+            const { O3, P, OP1, OP2, O5, confalScore } = points;
+            const scale = (confalScore - 20.0) / 100.0;
+            // Steps can be drawn in a different order than they are stored.
+            // To make sure that we can get from the drawn pyramid back to the step in represents,
+            // we need to use an appropriate groupId. The stepIdx passed from the iterator
+            // is an index into the array of all steps in the structure.
+            // Since a step is drawn as two "half-pyramids" we need two ids to map to a single step.
+            // To do that, we just multiply the index by 2. idx*2 marks the "upper" half-pyramid,
+            // (idx*2)+1 the "lower" half-pyramid.
+            const groupIdx = points.stepIdx * 2;
+
+            unit.conformation.invariantPosition(O3, posO3);
+            unit.conformation.invariantPosition(P, posP);
+            unit.conformation.invariantPosition(OP1, posOP1);
+            unit.conformation.invariantPosition(OP2, posOP2);
+            unit.conformation.invariantPosition(O5, posO5);
+
+            shiftVertex(posO3, posP, scale);
+            shiftVertex(posOP1, posP, scale);
+            shiftVertex(posOP2, posP, scale);
+            shiftVertex(posO5, posP, scale);
+            calcMidpoint(mp, posO3, posO5);
+
+            mb.currentGroup = groupIdx;
+            let pb = PrimitiveBuilder(3);
+            /* Upper part (for first residue in step) */
+            pb.add(posO3, posOP1, posOP2);
+            pb.add(posO3, mp, posOP1);
+            pb.add(posO3, posOP2, mp);
+            MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
+
+            /* Lower part (for second residue in step) */
+            mb.currentGroup = groupIdx + 1;
+            pb = PrimitiveBuilder(3);
+            pb.add(mp, posO5, posOP1);
+            pb.add(mp, posOP2, posO5);
+            pb.add(posO5, posOP2, posOP1);
+            MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
+        }
+    }
 
     return MeshBuilder.getMesh(mb);
 }
@@ -124,15 +142,17 @@ function getConfalPyramidLoci(pickingId: PickingId, structureGroup: StructureGro
     const unit = structureGroup.group.units[instanceId];
     if (!Unit.isAtomic(unit)) return EmptyLoci;
 
-    const prop = ConfalPyramidsProvider.get(structure.model).value;
-    if (prop === undefined || prop.data === undefined) return EmptyLoci;
+    const data = ConfalPyramidsProvider.get(structure.model)?.value?.data;
+    if (!data) return EmptyLoci;
+
+    const halfPyramidsCount = data.steps.length * 2;
 
-    const { halfPyramids } = prop.data;
+    if (halfPyramidsCount <= groupId) return EmptyLoci;
 
-    if (halfPyramids.length <= groupId) return EmptyLoci;
-    const hp = halfPyramids[groupId];
+    const idx = Math.floor(groupId / 2); // Map groupIndex to a step, see createConfalPyramidsMesh() for full explanation
+    const step = data.steps[idx];
 
-    return CPT.Loci(hp, [{}]);
+    return CPT.Loci({ step, isLower: groupId % 2 === 1 }, [{}]);
 }
 
 function eachConfalPyramid(loci: Loci, structureGroup: StructureGroup, apply: (interval: Interval) => boolean) {

+ 7 - 6
src/extensions/dnatco/confal-pyramids/types.ts

@@ -30,11 +30,12 @@ export namespace ConfalPyramidsTypes {
         rmsd: number,
     }
 
-    export interface StepsData {
+    export type MappedChains = Map<string, MappedResidues>;
+    export type MappedResidues = Map<number, number[]>;
+
+    export interface Steps {
         steps: Array<Step>,
-        names: Map<string, number>,
-        halfPyramids: Array<HalfPyramid>,
-        hasMultipleModels: boolean
+        mapping: MappedChains[],
     }
 
     export interface HalfPyramid {
@@ -44,8 +45,8 @@ export namespace ConfalPyramidsTypes {
 
     export interface Location extends DataLocation<HalfPyramid, {}> {}
 
-    export function Location(halfPyramid: HalfPyramid) {
-        return DataLocation(DataTag, halfPyramid, {});
+    export function Location(step: Step, isLower: boolean) {
+        return DataLocation(DataTag, { step, isLower }, {});
     }
 
     export function isLocation(x: any): x is Location {

+ 87 - 247
src/extensions/dnatco/confal-pyramids/util.ts

@@ -8,280 +8,120 @@
 import { ConfalPyramidsProvider } from './property';
 import { ConfalPyramidsTypes as CPT } from './types';
 import { Segmentation } from '../../../mol-data/int';
-import { Vec3 } from '../../../mol-math/linear-algebra';
 import { ChainIndex, ElementIndex, ResidueIndex, Structure, StructureElement, StructureProperties, Unit } from '../../../mol-model/structure';
 
-export namespace ConfalPyramidsUtil {
-    type Residue = Segmentation.Segment<ResidueIndex>;
+type Residue = Segmentation.Segment<ResidueIndex>;
 
-    export type AtomInfo = {
-        pos: Vec3,
-        index: ElementIndex,
-        fakeAltId: string,
-    };
+export type Pyramid = {
+    O3: ElementIndex,
+    P: ElementIndex,
+    OP1: ElementIndex,
+    OP2: ElementIndex,
+    O5: ElementIndex,
+    confalScore: number,
+    stepIdx: number,
+};
 
-    export type FirstResidueAtoms = {
-        O3: AtomInfo,
-    };
+const EmptyStepIndices = new Array<number>();
 
-    export type SecondResidueAtoms = {
-        OP1: AtomInfo,
-        OP2: AtomInfo,
-        O5: AtomInfo,
-        P: AtomInfo,
-    };
-
-    type ResidueInfo = {
-        PDB_model_num: number,
-        asym_id: string,
-        auth_asym_id: string,
-        seq_id: number,
-        auth_seq_id: number,
-        comp_id: string,
-        alt_id: string,
-        ins_code: string,
-    };
+function copyResidue(r?: Residue) {
+    return r ? { index: r.index, start: r.start, end: r.end } : void 0;
+}
 
-    export type Handler = (pyramid: CPT.Step, first: FirstResidueAtoms, second: SecondResidueAtoms, firstLocIndex: number, secondLocIndex: number) => void;
+function getAtomIndex(loc: StructureElement.Location, residue: Residue, names: string[], altId: string): ElementIndex {
+    for (let eI = residue.start; eI < residue.end; eI++) {
+        loc.element = loc.unit.elements[eI];
+        const elName = StructureProperties.atom.label_atom_id(loc);
+        const elAltId = StructureProperties.atom.label_alt_id(loc);
 
-    function residueInfoFromLocation(loc: StructureElement.Location): ResidueInfo {
-        return {
-            PDB_model_num: StructureProperties.unit.model_num(loc),
-            asym_id: StructureProperties.chain.label_asym_id(loc),
-            auth_asym_id: StructureProperties.chain.auth_asym_id(loc),
-            seq_id: StructureProperties.residue.label_seq_id(loc),
-            auth_seq_id: StructureProperties.residue.auth_seq_id(loc),
-            comp_id: StructureProperties.atom.label_comp_id(loc),
-            alt_id: StructureProperties.atom.label_alt_id(loc),
-            ins_code: StructureProperties.residue.pdbx_PDB_ins_code(loc)
-        };
+        if (names.includes(elName) && (elAltId === altId || elAltId.length === 0))
+            return loc.element;
     }
 
-    export function hasMultipleModels(unit: Unit.Atomic): boolean {
-        const prop = ConfalPyramidsProvider.get(unit.model).value;
-        if (prop === undefined || prop.data === undefined) throw new Error('No custom properties data');
-        return prop.data.hasMultipleModels;
-    }
+    return -1 as ElementIndex;
+}
 
-    function getPossibleAltIds(residue: Residue, structure: Structure, unit: Unit.Atomic): string[] {
-        const possibleAltIds: string[] = [];
+function getPyramid(loc: StructureElement.Location, one: Residue, two: Residue, altIdOne: string, altIdTwo: string, confalScore: number, stepIdx: number): Pyramid {
+    const O3 = getAtomIndex(loc, one, ['O3\'', 'O3*'], altIdOne);
+    const P = getAtomIndex(loc, two, ['P'], altIdTwo);
+    const OP1 = getAtomIndex(loc, two, ['OP1'], altIdTwo);
+    const OP2 = getAtomIndex(loc, two, ['OP2'], altIdTwo);
+    const O5 = getAtomIndex(loc, two, ['O5\'', 'O5*'], altIdTwo);
 
-        const loc = StructureElement.Location.create(structure, unit, -1 as ElementIndex);
-        for (let rI = residue.start; rI <= residue.end - 1; rI++) {
-            loc.element = unit.elements[rI];
-            const altId = StructureProperties.atom.label_alt_id(loc);
-            if (altId !== '' && !possibleAltIds.includes(altId)) possibleAltIds.push(altId);
-        }
+    return { O3, P, OP1, OP2, O5, confalScore, stepIdx };
+}
 
-        return possibleAltIds;
+export class ConfalPyramidsIterator {
+    private chainIt: Segmentation.SegmentIterator<ChainIndex>;
+    private residueIt: Segmentation.SegmentIterator<ResidueIndex>;
+    private residueOne?: Residue;
+    private residueTwo: Residue;
+    private data?: CPT.Steps;
+    private loc: StructureElement.Location;
+
+    private getStepIndices(r: Residue) {
+        this.loc.element = this.loc.unit.elements[r.start];
+
+        const modelIdx = StructureProperties.unit.model_num(this.loc) - 1;
+        const chainId = StructureProperties.chain.auth_asym_id(this.loc);
+        const seqId = StructureProperties.residue.auth_seq_id(this.loc);
+
+        const chains = this.data!.mapping[modelIdx];
+        if (!chains) return EmptyStepIndices;
+        const residues = chains.get(chainId);
+        if (!residues) return EmptyStepIndices;
+        return residues.get(seqId) ?? EmptyStepIndices;
     }
 
-    class Utility {
-        protected getPyramidByName(name: string): { pyramid: CPT.Step | undefined, index: number } {
-            const index = this.data.names.get(name);
-            if (index === undefined) return { pyramid: undefined, index: -1 };
-
-            return { pyramid: this.data.steps[index], index };
-        }
-
-        protected stepToName(entry_id: string, modelNum: number, locFirst: StructureElement.Location, locSecond: StructureElement.Location, fakeAltId_1: string, fakeAltId_2: string) {
-            const first = residueInfoFromLocation(locFirst);
-            const second = residueInfoFromLocation(locSecond);
-            const model_id = this.hasMultipleModels ? `-m${modelNum}` : '';
-            const alt_id_1 = fakeAltId_1 !== '' ? `.${fakeAltId_1}` : (first.alt_id.length ? `.${first.alt_id}` : '');
-            const alt_id_2 = fakeAltId_2 !== '' ? `.${fakeAltId_2}` : (second.alt_id.length ? `.${second.alt_id}` : '');
-            const ins_code_1 = first.ins_code.length ? `.${first.ins_code}` : '';
-            const ins_code_2 = second.ins_code.length ? `.${second.ins_code}` : '';
-
-            return `${entry_id}${model_id}_${first.auth_asym_id}_${first.comp_id}${alt_id_1}_${first.auth_seq_id}${ins_code_1}_${second.comp_id}${alt_id_2}_${second.auth_seq_id}${ins_code_2}`;
-        }
-
-        constructor(unit: Unit.Atomic) {
-            const prop = ConfalPyramidsProvider.get(unit.model).value;
-            if (prop === undefined || prop.data === undefined) throw new Error('No custom properties data');
-
-            this.data = prop.data;
-            this.hasMultipleModels = hasMultipleModels(unit);
+    private moveStep() {
+        this.residueOne = copyResidue(this.residueTwo);
+        this.residueTwo = copyResidue(this.residueIt.move())!;
 
-            this.entryId = unit.model.entryId.toLowerCase();
-            this.modelNum = unit.model.modelNum;
-        }
-
-        protected readonly data: CPT.StepsData;
-        protected readonly hasMultipleModels: boolean;
-        protected readonly entryId: string;
-        protected readonly modelNum: number;
+        return this.toPyramids(this.residueOne!, this.residueTwo);
     }
 
-    export class UnitWalker extends Utility {
-        private getAtomIndices(names: string[], residue: Residue): ElementIndex[] {
-            const indices: ElementIndex[] = [];
-
-            const loc = StructureElement.Location.create(this.structure, this.unit, -1 as ElementIndex);
-            for (let rI = residue.start; rI <= residue.end - 1; rI++) {
-                loc.element = this.unit.elements[rI];
-                const thisName = StructureProperties.atom.label_atom_id(loc);
-                if (names.includes(thisName)) indices.push(loc.element);
-            }
-
-            if (indices.length === 0) {
-                let namesStr = '';
-                for (const n of names)
-                    namesStr += `${n} `;
+    private toPyramids(one: Residue, two: Residue) {
+        const indices = this.getStepIndices(one);
 
-                throw new Error(`Element [${namesStr}] not found on residue ${residue.index}`);
-            }
-
-            return indices;
+        const points = [];
+        for (const idx of indices) {
+            const step = this.data!.steps[idx];
+            points.push(getPyramid(this.loc, one, two, step.label_alt_id_1, step.label_alt_id_2, step.confal_score, idx));
         }
 
-        private getAtomPositions(indices: ElementIndex[]): Vec3[] {
-            const pos = this.unit.conformation.invariantPosition;
-            const positions: Vec3[] = [];
-
-            for (const eI of indices) {
-                const v = Vec3.zero();
-                pos(eI, v);
-                positions.push(v);
-            }
-
-            return positions;
-        }
-
-        private handleStep(firstAtoms: FirstResidueAtoms[], secondAtoms: SecondResidueAtoms[]) {
-            const modelNum = this.hasMultipleModels ? this.modelNum : -1;
-            let ok = false;
-
-            const firstLoc = StructureElement.Location.create(this.structure, this.unit, -1 as ElementIndex);
-            const secondLoc = StructureElement.Location.create(this.structure, this.unit, -1 as ElementIndex);
-            for (let i = 0; i < firstAtoms.length; i++) {
-                const first = firstAtoms[i];
-                for (let j = 0; j < secondAtoms.length; j++) {
-                    const second = secondAtoms[j];
-                    firstLoc.element = first.O3.index;
-                    secondLoc.element = second.OP1.index;
-
-                    const name = this.stepToName(this.entryId, modelNum, firstLoc, secondLoc, first.O3.fakeAltId, second.OP1.fakeAltId);
-                    const { pyramid, index } = this.getPyramidByName(name);
-                    if (pyramid !== undefined) {
-                        const locIndex = index * 2;
-                        this.handler(pyramid, first, second, locIndex, locIndex + 1);
-                        ok = true;
-                    }
-                }
-            }
-
-            if (!ok) throw new Error('Bogus step');
-        }
-
-        private processFirstResidue(residue: Residue, possibleAltIds: string[]) {
-            const indO3 = this.getAtomIndices(['O3\'', 'O3*'], residue);
-            const posO3 = this.getAtomPositions(indO3);
-
-            const altPos: FirstResidueAtoms[] = [
-                { O3: { pos: posO3[0], index: indO3[0], fakeAltId: '' } }
-            ];
-
-            for (let i = 1; i < indO3.length; i++) {
-                altPos.push({ O3: { pos: posO3[i], index: indO3[i], fakeAltId: '' } });
-            }
-
-            if (altPos.length === 1 && possibleAltIds.length > 1) {
-                /* We have some alternate positions on the residue but O3 does not have any - fake them */
-                altPos[0].O3.fakeAltId = possibleAltIds[0];
-
-                for (let i = 1; i < possibleAltIds.length; i++)
-                    altPos.push({ O3: { pos: posO3[0], index: indO3[0], fakeAltId: possibleAltIds[i] } });
-            }
-
-            return altPos;
-        }
-
-        private processSecondResidue(residue: Residue, possibleAltIds: string[]) {
-            const indOP1 = this.getAtomIndices(['OP1'], residue);
-            const indOP2 = this.getAtomIndices(['OP2'], residue);
-            const indO5 = this.getAtomIndices(['O5\'', 'O5*'], residue);
-            const indP = this.getAtomIndices(['P'], residue);
-
-            const posOP1 = this.getAtomPositions(indOP1);
-            const posOP2 = this.getAtomPositions(indOP2);
-            const posO5 = this.getAtomPositions(indO5);
-            const posP = this.getAtomPositions(indP);
-
-            const infoOP1: AtomInfo[] = [];
-            /* We use OP1 as "pivotal" atom. There is no specific reason
-             * to pick OP1, it is as good a choice as any other atom
-             */
-            if (indOP1.length === 1 && possibleAltIds.length > 1) {
-                /* No altIds on OP1, fake them */
-                for (const altId of possibleAltIds)
-                    infoOP1.push({ pos: posOP1[0], index: indOP1[0], fakeAltId: altId });
-            } else {
-                for (let i = 0; i < indOP1.length; i++)
-                    infoOP1.push({ pos: posOP1[i], index: indOP1[i], fakeAltId: '' });
-            }
-
-            const mkInfo = (i: number, indices: ElementIndex[], positions: Vec3[], altId: string) => {
-                if (i >= indices.length) {
-                    const last = indices.length - 1;
-                    return { pos: positions[last], index: indices[last], fakeAltId: altId };
-                }
-
-                return { pos: positions[i], index: indices[i], fakeAltId: altId };
-            };
-
-            const altPos: SecondResidueAtoms[] = [];
-            for (let i = 0; i < infoOP1.length; i++) {
-                const altId = infoOP1[i].fakeAltId;
-
-                const OP2 = mkInfo(i, indOP2, posOP2, altId);
-                const O5 = mkInfo(i, indO5, posO5, altId);
-                const P = mkInfo(i, indP, posP, altId);
-
-                altPos.push({ OP1: infoOP1[i], OP2, O5, P });
-            }
-
-            return altPos;
-        }
-
-        private step(residue: Residue): { firstAtoms: FirstResidueAtoms[], secondAtoms: SecondResidueAtoms[] } {
-            const firstPossibleAltIds = getPossibleAltIds(residue, this.structure, this.unit);
-            const firstAtoms = this.processFirstResidue(residue, firstPossibleAltIds);
+        return points;
+    }
 
-            residue = this.residueIt.move();
+    constructor(structure: Structure, unit: Unit) {
+        this.chainIt = Segmentation.transientSegments(unit.model.atomicHierarchy.chainAtomSegments, unit.elements);
+        this.residueIt = Segmentation.transientSegments(unit.model.atomicHierarchy.residueAtomSegments, unit.elements);
 
-            const secondPossibleAltIds = getPossibleAltIds(residue, this.structure, this.unit);
-            const secondAtoms = this.processSecondResidue(residue, secondPossibleAltIds);
+        const prop = ConfalPyramidsProvider.get(unit.model).value;
+        this.data = prop?.data;
 
-            return { firstAtoms, secondAtoms };
+        if (this.chainIt.hasNext) {
+            this.residueIt.setSegment(this.chainIt.move());
+            if (this.residueIt.hasNext)
+                this.residueTwo = this.residueIt.move();
         }
 
-        walk() {
-            while (this.chainIt.hasNext) {
-                this.residueIt.setSegment(this.chainIt.move());
-
-                let residue = this.residueIt.move();
-                while (this.residueIt.hasNext) {
-                    try {
-                        const { firstAtoms, secondAtoms } = this.step(residue);
-
-                        this.handleStep(firstAtoms, secondAtoms);
-                    } catch (error) {
-                        /* Skip and move along */
-                        residue = this.residueIt.move();
-                    }
-                }
-            }
-        }
+        this.loc = StructureElement.Location.create(structure, unit, -1 as ElementIndex);
+    }
 
-        constructor(private structure: Structure, private unit: Unit.Atomic, private handler: Handler) {
-            super(unit);
+    get hasNext() {
+        if (!this.data)
+            return false;
+        return this.residueIt.hasNext
+            ? true
+            : this.chainIt.hasNext;
+    }
 
-            this.chainIt = Segmentation.transientSegments(unit.model.atomicHierarchy.chainAtomSegments, unit.elements);
-            this.residueIt = Segmentation.transientSegments(unit.model.atomicHierarchy.residueAtomSegments, unit.elements);
+    move() {
+        if (this.residueIt.hasNext) {
+            return this.moveStep();
+        } else {
+            this.residueIt.setSegment(this.chainIt.move());
+            return this.moveStep();
         }
-
-        private chainIt: Segmentation.SegmentIterator<ChainIndex>;
-        private residueIt: Segmentation.SegmentIterator<ResidueIndex>;
     }
 }

+ 1 - 0
src/extensions/mp4-export/encoder.ts

@@ -69,6 +69,7 @@ export async function encodeMp4Animation<A extends PluginStateAnimation>(plugin:
         const dt = durationMs / N;
 
         await ctx.update({ message: 'Rendering...', isIndeterminate: false, current: 0, max: N + 1 });
+        await params.pass.updateBackground();
 
         await plugin.managers.animation.play(params.animation.definition, params.animation.params);
         stoppedAnimation = false;

+ 1 - 4
src/extensions/rcsb/graphql/types.ts

@@ -4,7 +4,7 @@ export type InputMaybe<T> = Maybe<T>;
 export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
 export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
 export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
-// Generated on 2022-06-26T14:02:35-07:00
+// Generated on 2022-08-20T16:36:05-07:00
 
 /** All built-in and custom scalars, mapped to their actual values */
 export type Scalars = {
@@ -13,11 +13,8 @@ export type Scalars = {
   Boolean: boolean;
   Int: number;
   Float: number;
-  /** Built-in scalar representing an instant in time */
   Date: any;
-  /** Built-in scalar for dynamic values */
   ObjectScalar: any;
-  /** Use SPQR's SchemaPrinter to remove this from SDL */
   UNREPRESENTABLE: any;
 };
 

+ 43 - 10
src/mol-canvas3d/canvas3d.ts

@@ -40,6 +40,9 @@ import { Passes } from './passes/passes';
 import { shallowEqual } from '../mol-util';
 import { MarkingParams } from './passes/marking';
 import { GraphicsRenderVariantsBlended, GraphicsRenderVariantsWboit } from '../mol-gl/webgl/render-item';
+import { degToRad, radToDeg } from '../mol-math/misc';
+import { AssetManager } from '../mol-util/assets';
+import { deepClone } from '../mol-util/object';
 
 export const Canvas3DParams = {
     camera: PD.Group({
@@ -49,6 +52,7 @@ export const Canvas3DParams = {
             on: PD.Group(StereoCameraParams),
             off: PD.Group({})
         }, { cycle: true, hideIf: p => p?.mode !== 'perspective' }),
+        fov: PD.Numeric(45, { min: 10, max: 130, step: 1 }, { label: 'Field of View' }),
         manualReset: PD.Boolean(false, { isHidden: true }),
     }, { pivot: 'mode' }),
     cameraFog: PD.MappedStatic('on', {
@@ -78,6 +82,7 @@ export const Canvas3DParams = {
     }),
 
     cameraResetDurationMs: PD.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'The time it takes to reset the camera.' }),
+    sceneRadiusFactor: PD.Numeric(1, { min: 1, max: 10, step: 0.1 }),
     transparentBackground: PD.Boolean(false),
 
     multiSample: PD.Group(MultiSampleParams),
@@ -106,6 +111,7 @@ interface Canvas3DContext {
     readonly attribs: Readonly<Canvas3DContext.Attribs>
     readonly contextLost: BehaviorSubject<now.Timestamp>
     readonly contextRestored: BehaviorSubject<now.Timestamp>
+    readonly assetManager: AssetManager
     dispose: (options?: Partial<{ doNotForceWebGLContextLoss: boolean }>) => void
 }
 
@@ -124,7 +130,7 @@ namespace Canvas3DContext {
     };
     export type Attribs = typeof DefaultAttribs
 
-    export function fromCanvas(canvas: HTMLCanvasElement, attribs: Partial<Attribs> = {}): Canvas3DContext {
+    export function fromCanvas(canvas: HTMLCanvasElement, assetManager: AssetManager, attribs: Partial<Attribs> = {}): Canvas3DContext {
         const a = { ...DefaultAttribs, ...attribs };
         const { antialias, preserveDrawingBuffer, pixelScale, preferWebGl1 } = a;
         const gl = getGLContext(canvas, {
@@ -139,7 +145,7 @@ namespace Canvas3DContext {
 
         const input = InputObserver.fromElement(canvas, { pixelScale, preventGestures: true });
         const webgl = createContext(gl, { pixelScale });
-        const passes = new Passes(webgl, a);
+        const passes = new Passes(webgl, assetManager, a);
 
         if (isDebugMode) {
             const loseContextExt = gl.getExtension('WEBGL_lose_context');
@@ -192,6 +198,7 @@ namespace Canvas3DContext {
             attribs: a,
             contextLost,
             contextRestored: webgl.contextRestored,
+            assetManager,
             dispose: (options?: Partial<{ doNotForceWebGLContextLoss: boolean }>) => {
                 input.dispose();
 
@@ -278,8 +285,8 @@ namespace Canvas3D {
     export interface DragEvent { current: Representation.Loci, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, pageStart: Vec2, pageEnd: Vec2 }
     export interface ClickEvent { current: Representation.Loci, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, page?: Vec2, position?: Vec3 }
 
-    export function create({ webgl, input, passes, attribs }: Canvas3DContext, props: Partial<Canvas3DProps> = {}): Canvas3D {
-        const p: Canvas3DProps = { ...DefaultCanvas3DParams, ...props };
+    export function create({ webgl, input, passes, attribs, assetManager }: Canvas3DContext, props: Partial<Canvas3DProps> = {}): Canvas3D {
+        const p: Canvas3DProps = { ...deepClone(DefaultCanvas3DParams), ...deepClone(props) };
 
         const reprRenderObjects = new Map<Representation.Any, Set<GraphicsRenderObject>>();
         const reprUpdatedSubscriptions = new Map<Representation.Any, Subscription>();
@@ -299,11 +306,16 @@ namespace Canvas3D {
 
         const scene = Scene.create(webgl, passes.draw.wboitEnabled ? GraphicsRenderVariantsWboit : GraphicsRenderVariantsBlended);
 
+        function getSceneRadius() {
+            return scene.boundingSphere.radius * p.sceneRadiusFactor;
+        }
+
         const camera = new Camera({
             position: Vec3.create(0, 0, 100),
             mode: p.camera.mode,
             fog: p.cameraFog.name === 'on' ? p.cameraFog.params.intensity : 0,
-            clipFar: p.cameraClipping.far
+            clipFar: p.cameraClipping.far,
+            fov: degToRad(p.camera.fov),
         }, { x, y, width, height }, { pixelScale: attribs.pixelScale });
         const stereoCamera = new StereoCamera(camera, p.camera.stereo.params);
 
@@ -315,6 +327,10 @@ namespace Canvas3D {
         const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, p.interaction);
         const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
 
+        passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
+            if (changed) requestDraw();
+        });
+
         let cameraResetRequested = false;
         let nextCameraResetDuration: number | undefined = void 0;
         let nextCameraResetSnapshot: Camera.SnapshotProvider | undefined = void 0;
@@ -523,7 +539,7 @@ namespace Canvas3D {
                 const focus = camera.getFocus(center, radius);
                 const next = typeof nextCameraResetSnapshot === 'function' ? nextCameraResetSnapshot(scene, camera) : nextCameraResetSnapshot;
                 const snapshot = next ? { ...focus, ...next } : focus;
-                camera.setState({ ...snapshot, radiusMax: scene.boundingSphere.radius }, duration);
+                camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration);
             }
 
             nextCameraResetDuration = void 0;
@@ -574,7 +590,7 @@ namespace Canvas3D {
             }
             if (oldBoundingSphereVisible.radius === 0) nextCameraResetDuration = 0;
 
-            if (!p.camera.manualReset) camera.setState({ radiusMax: scene.boundingSphere.radius }, 0);
+            if (!p.camera.manualReset) camera.setState({ radiusMax: getSceneRadius() }, 0);
             reprCount.next(reprRenderObjects.size);
             if (isDebugMode) consoleStats();
 
@@ -650,7 +666,7 @@ namespace Canvas3D {
 
         function getProps(): Canvas3DProps {
             const radius = scene.boundingSphere.radius > 0
-                ? 100 - Math.round((camera.transition.target.radius / scene.boundingSphere.radius) * 100)
+                ? 100 - Math.round((camera.transition.target.radius / getSceneRadius()) * 100)
                 : 0;
 
             return {
@@ -658,6 +674,7 @@ namespace Canvas3D {
                     mode: camera.state.mode,
                     helper: { ...helper.camera.props },
                     stereo: { ...p.camera.stereo },
+                    fov: Math.round(radToDeg(camera.state.fov)),
                     manualReset: !!p.camera.manualReset
                 },
                 cameraFog: camera.state.fog > 0
@@ -665,6 +682,7 @@ namespace Canvas3D {
                     : { name: 'off' as const, params: {} },
                 cameraClipping: { far: camera.state.clipFar, radius },
                 cameraResetDurationMs: p.cameraResetDurationMs,
+                sceneRadiusFactor: p.sceneRadiusFactor,
                 transparentBackground: p.transparentBackground,
                 viewport: p.viewport,
 
@@ -767,10 +785,19 @@ namespace Canvas3D {
                     ? produce(getProps(), properties as any)
                     : properties;
 
+                if (props.sceneRadiusFactor !== undefined) {
+                    p.sceneRadiusFactor = props.sceneRadiusFactor;
+                    camera.setState({ radiusMax: getSceneRadius() }, 0);
+                }
+
                 const cameraState: Partial<Camera.Snapshot> = Object.create(null);
                 if (props.camera && props.camera.mode !== undefined && props.camera.mode !== camera.state.mode) {
                     cameraState.mode = props.camera.mode;
                 }
+                const oldFov = Math.round(radToDeg(camera.state.fov));
+                if (props.camera && props.camera.fov !== undefined && props.camera.fov !== oldFov) {
+                    cameraState.fov = degToRad(props.camera.fov);
+                }
                 if (props.cameraFog !== undefined && props.cameraFog.params) {
                     const newFog = props.cameraFog.name === 'on' ? props.cameraFog.params.intensity : 0;
                     if (newFog !== camera.state.fog) cameraState.fog = newFog;
@@ -780,7 +807,7 @@ namespace Canvas3D {
                         cameraState.clipFar = props.cameraClipping.far;
                     }
                     if (props.cameraClipping.radius !== undefined) {
-                        const radius = (scene.boundingSphere.radius / 100) * (100 - props.cameraClipping.radius);
+                        const radius = (getSceneRadius() / 100) * (100 - props.cameraClipping.radius);
                         if (radius > 0 && radius !== cameraState.radius) {
                             // if radius = 0, NaNs happen
                             cameraState.radius = Math.max(radius, 0.01);
@@ -805,6 +832,12 @@ namespace Canvas3D {
                     }
                 }
 
+                if (props.postprocessing?.background) {
+                    Object.assign(p.postprocessing.background, props.postprocessing.background);
+                    passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
+                        if (changed && !doNotRequestDraw) requestDraw();
+                    });
+                }
                 if (props.postprocessing) Object.assign(p.postprocessing, props.postprocessing);
                 if (props.marking) Object.assign(p.marking, props.marking);
                 if (props.multiSample) Object.assign(p.multiSample, props.multiSample);
@@ -823,7 +856,7 @@ namespace Canvas3D {
                 }
             },
             getImagePass: (props: Partial<ImageProps> = {}) => {
-                return new ImagePass(webgl, renderer, scene, camera, helper, passes.draw.wboitEnabled, props);
+                return new ImagePass(webgl, assetManager, renderer, scene, camera, helper, passes.draw.wboitEnabled, props);
             },
             getRenderObjects(): GraphicsRenderObject[] {
                 const renderObjects: GraphicsRenderObject[] = [];

+ 461 - 0
src/mol-canvas3d/passes/background.ts

@@ -0,0 +1,461 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { QuadPositions, } from '../../mol-gl/compute/util';
+import { ComputeRenderable, createComputeRenderable } from '../../mol-gl/renderable';
+import { AttributeSpec, DefineSpec, TextureSpec, UniformSpec, Values, ValueSpec } from '../../mol-gl/renderable/schema';
+import { ShaderCode } from '../../mol-gl/shader-code';
+import { background_frag } from '../../mol-gl/shader/background.frag';
+import { background_vert } from '../../mol-gl/shader/background.vert';
+import { WebGLContext } from '../../mol-gl/webgl/context';
+import { createComputeRenderItem } from '../../mol-gl/webgl/render-item';
+import { createNullTexture, CubeFaces, Texture } from '../../mol-gl/webgl/texture';
+import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
+import { ValueCell } from '../../mol-util/value-cell';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { isTimingMode } from '../../mol-util/debug';
+import { Camera, ICamera } from '../camera';
+import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
+import { Vec2 } from '../../mol-math/linear-algebra/3d/vec2';
+import { Color } from '../../mol-util/color';
+import { Asset, AssetManager } from '../../mol-util/assets';
+import { Vec4 } from '../../mol-math/linear-algebra/3d/vec4';
+
+const SharedParams = {
+    opacity: PD.Numeric(1, { min: 0.0, max: 1.0, step: 0.01 }),
+    saturation: PD.Numeric(0, { min: -1, max: 1, step: 0.01 }),
+    lightness: PD.Numeric(0, { min: -1, max: 1, step: 0.01 }),
+};
+
+const SkyboxParams = {
+    faces: PD.MappedStatic('urls', {
+        urls: PD.Group({
+            nx: PD.Text('', { label: 'Negative X / Left' }),
+            ny: PD.Text('', { label: 'Negative Y / Bottom' }),
+            nz: PD.Text('', { label: 'Negative Z / Back' }),
+            px: PD.Text('', { label: 'Positive X / Right' }),
+            py: PD.Text('', { label: 'Positive Y / Top' }),
+            pz: PD.Text('', { label: 'Positive Z / Front' }),
+        }, { isExpanded: true, label: 'URLs' }),
+        files: PD.Group({
+            nx: PD.File({ label: 'Negative X / Left', accept: 'image/*' }),
+            ny: PD.File({ label: 'Negative Y / Bottom', accept: 'image/*' }),
+            nz: PD.File({ label: 'Negative Z / Back', accept: 'image/*' }),
+            px: PD.File({ label: 'Positive X / Right', accept: 'image/*' }),
+            py: PD.File({ label: 'Positive Y / Top', accept: 'image/*' }),
+            pz: PD.File({ label: 'Positive Z / Front', accept: 'image/*' }),
+        }, { isExpanded: true, label: 'Files' }),
+    }),
+    ...SharedParams,
+};
+type SkyboxProps = PD.Values<typeof SkyboxParams>
+
+const ImageParams = {
+    source: PD.MappedStatic('url', {
+        url: PD.Text(''),
+        file: PD.File({ accept: 'image/*' }),
+    }),
+    ...SharedParams,
+    coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
+};
+type ImageProps = PD.Values<typeof ImageParams>
+
+const HorizontalGradientParams = {
+    topColor: PD.Color(Color(0xDDDDDD)),
+    bottomColor: PD.Color(Color(0xEEEEEE)),
+    ratio: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
+    coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
+};
+
+const RadialGradientParams = {
+    centerColor: PD.Color(Color(0xDDDDDD)),
+    edgeColor: PD.Color(Color(0xEEEEEE)),
+    ratio: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
+    coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
+};
+
+export const BackgroundParams = {
+    variant: PD.MappedStatic('off', {
+        off: PD.EmptyGroup(),
+        skybox: PD.Group(SkyboxParams, { isExpanded: true }),
+        image: PD.Group(ImageParams, { isExpanded: true }),
+        horizontalGradient: PD.Group(HorizontalGradientParams, { isExpanded: true }),
+        radialGradient: PD.Group(RadialGradientParams, { isExpanded: true }),
+    }, { label: 'Environment' }),
+};
+export type BackgroundProps = PD.Values<typeof BackgroundParams>
+
+export class BackgroundPass {
+    private renderable: BackgroundRenderable;
+
+    private skybox: {
+        texture: Texture
+        props: SkyboxProps
+        assets: Asset[]
+        loaded: boolean
+    } | undefined;
+
+    private image: {
+        texture: Texture
+        props: ImageProps
+        asset: Asset
+        loaded: boolean
+    } | undefined;
+
+    private readonly camera = new Camera();
+    private readonly target = Vec3();
+    private readonly position = Vec3();
+    private readonly dir = Vec3();
+
+    readonly texture: Texture;
+
+    constructor(private readonly webgl: WebGLContext, private readonly assetManager: AssetManager, width: number, height: number) {
+        this.renderable = getBackgroundRenderable(webgl, width, height);
+    }
+
+    setSize(width: number, height: number) {
+        const [w, h] = this.renderable.values.uTexSize.ref.value;
+
+        if (width !== w || height !== h) {
+            ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
+        }
+    }
+
+    private clearSkybox() {
+        if (this.skybox !== undefined) {
+            this.skybox.texture.destroy();
+            this.skybox.assets.forEach(a => this.assetManager.release(a));
+            this.skybox = undefined;
+        }
+    }
+
+    private updateSkybox(camera: ICamera, props: SkyboxProps, onload?: (changed: boolean) => void) {
+        const tf = this.skybox?.props.faces;
+        const f = props.faces.params;
+        if (!f.nx || !f.ny || !f.nz || !f.px || !f.py || !f.pz) {
+            this.clearSkybox();
+            onload?.(false);
+            return;
+        }
+        if (!this.skybox || !tf || !areSkyboxTexturePropsEqual(props.faces, this.skybox.props.faces)) {
+            this.clearSkybox();
+            const { texture, assets } = getSkyboxTexture(this.webgl, this.assetManager, props.faces, errored => {
+                if (this.skybox) this.skybox.loaded = !errored;
+                onload?.(true);
+            });
+            this.skybox = { texture, props: { ...props }, assets, loaded: false };
+            ValueCell.update(this.renderable.values.tSkybox, texture);
+            this.renderable.update();
+        } else {
+            onload?.(false);
+        }
+        if (!this.skybox) return;
+
+        let cam = camera;
+        if (camera.state.mode === 'orthographic') {
+            this.camera.setState({ ...camera.state, mode: 'perspective' });
+            this.camera.update();
+            cam = this.camera;
+        }
+
+        const m = this.renderable.values.uViewDirectionProjectionInverse.ref.value;
+        Vec3.sub(this.dir, cam.state.position, cam.state.target);
+        Vec3.setMagnitude(this.dir, this.dir, 0.1);
+        Vec3.copy(this.position, this.dir);
+        Mat4.lookAt(m, this.position, this.target, cam.state.up);
+        Mat4.mul(m, cam.projection, m);
+        Mat4.invert(m, m);
+        ValueCell.update(this.renderable.values.uViewDirectionProjectionInverse, m);
+
+        ValueCell.updateIfChanged(this.renderable.values.uOpacity, props.opacity);
+        ValueCell.updateIfChanged(this.renderable.values.uSaturation, props.saturation);
+        ValueCell.updateIfChanged(this.renderable.values.uLightness, props.lightness);
+        ValueCell.updateIfChanged(this.renderable.values.dVariant, 'skybox');
+        this.renderable.update();
+    }
+
+    private clearImage() {
+        if (this.image !== undefined) {
+            this.image.texture.destroy();
+            this.assetManager.release(this.image.asset);
+            this.image = undefined;
+        }
+    }
+
+    private updateImage(props: ImageProps, onload?: (loaded: boolean) => void) {
+        if (!props.source.params) {
+            this.clearImage();
+            onload?.(false);
+            return;
+        }
+        if (!this.image || !this.image.props.source.params || !areImageTexturePropsEqual(props.source, this.image.props.source)) {
+            this.clearImage();
+            const { texture, asset } = getImageTexture(this.webgl, this.assetManager, props.source, errored => {
+                if (this.image) this.image.loaded = !errored;
+                onload?.(true);
+            });
+            this.image = { texture, props: { ...props }, asset, loaded: false };
+            ValueCell.update(this.renderable.values.tImage, texture);
+            this.renderable.update();
+        } else {
+            onload?.(false);
+        }
+        if (!this.image) return;
+
+        ValueCell.updateIfChanged(this.renderable.values.uOpacity, props.opacity);
+        ValueCell.updateIfChanged(this.renderable.values.uSaturation, props.saturation);
+        ValueCell.updateIfChanged(this.renderable.values.uLightness, props.lightness);
+        ValueCell.updateIfChanged(this.renderable.values.uViewportAdjusted, props.coverage === 'viewport' ? true : false);
+        ValueCell.updateIfChanged(this.renderable.values.dVariant, 'image');
+        this.renderable.update();
+    }
+
+    private updateImageScaling() {
+        const v = this.renderable.values;
+        const [w, h] = v.uTexSize.ref.value;
+        const iw = this.image?.texture.getWidth() || 0;
+        const ih = this.image?.texture.getHeight() || 0;
+        const r = w / h;
+        const ir = iw / ih;
+        // responsive scaling with offset
+        if (r < ir) {
+            ValueCell.update(v.uImageScale, Vec2.set(v.uImageScale.ref.value, iw * h / ih, h));
+        } else {
+            ValueCell.update(v.uImageScale, Vec2.set(v.uImageScale.ref.value, w, ih * w / iw));
+        }
+        const [rw, rh] = v.uImageScale.ref.value;
+        const sr = rw / rh;
+        if (sr > r) {
+            ValueCell.update(v.uImageOffset, Vec2.set(v.uImageOffset.ref.value, (1 - r / sr) / 2, 0));
+        } else {
+            ValueCell.update(v.uImageOffset, Vec2.set(v.uImageOffset.ref.value, 0, (1 - sr / r) / 2));
+        }
+    }
+
+    private updateGradient(colorA: Color, colorB: Color, ratio: number, variant: 'horizontalGradient' | 'radialGradient', viewportAdjusted: boolean) {
+        ValueCell.update(this.renderable.values.uGradientColorA, Color.toVec3Normalized(this.renderable.values.uGradientColorA.ref.value, colorA));
+        ValueCell.update(this.renderable.values.uGradientColorB, Color.toVec3Normalized(this.renderable.values.uGradientColorB.ref.value, colorB));
+        ValueCell.updateIfChanged(this.renderable.values.uGradientRatio, ratio);
+        ValueCell.updateIfChanged(this.renderable.values.uViewportAdjusted, viewportAdjusted);
+        ValueCell.updateIfChanged(this.renderable.values.dVariant, variant);
+        this.renderable.update();
+    }
+
+    update(camera: ICamera, props: BackgroundProps, onload?: (changed: boolean) => void) {
+        if (props.variant.name === 'off') {
+            this.clearSkybox();
+            this.clearImage();
+            onload?.(false);
+            return;
+        } else if (props.variant.name === 'skybox') {
+            this.clearImage();
+            this.updateSkybox(camera, props.variant.params, onload);
+        } else if (props.variant.name === 'image') {
+            this.clearSkybox();
+            this.updateImage(props.variant.params, onload);
+        } else if (props.variant.name === 'horizontalGradient') {
+            this.clearSkybox();
+            this.clearImage();
+            this.updateGradient(props.variant.params.topColor, props.variant.params.bottomColor, props.variant.params.ratio, props.variant.name, props.variant.params.coverage === 'viewport' ? true : false);
+            onload?.(false);
+        } else if (props.variant.name === 'radialGradient') {
+            this.clearSkybox();
+            this.clearImage();
+            this.updateGradient(props.variant.params.centerColor, props.variant.params.edgeColor, props.variant.params.ratio, props.variant.name, props.variant.params.coverage === 'viewport' ? true : false);
+            onload?.(false);
+        }
+
+        const { x, y, width, height } = camera.viewport;
+        ValueCell.update(this.renderable.values.uViewport, Vec4.set(this.renderable.values.uViewport.ref.value, x, y, width, height));
+    }
+
+    isEnabled(props: BackgroundProps) {
+        return !!(
+            (this.skybox && this.skybox.loaded) ||
+            (this.image && this.image.loaded) ||
+            props.variant.name === 'horizontalGradient' ||
+            props.variant.name === 'radialGradient'
+        );
+    }
+
+    private isReady() {
+        return !!(
+            (this.skybox && this.skybox.loaded) ||
+            (this.image && this.image.loaded) ||
+            this.renderable.values.dVariant.ref.value === 'horizontalGradient' ||
+            this.renderable.values.dVariant.ref.value === 'radialGradient'
+        );
+    }
+
+    render() {
+        if (!this.isReady()) return;
+
+        if (this.renderable.values.dVariant.ref.value === 'image') {
+            this.updateImageScaling();
+        }
+
+        if (isTimingMode) this.webgl.timer.mark('BackgroundPass.render');
+        this.renderable.render();
+        if (isTimingMode) this.webgl.timer.markEnd('BackgroundPass.render');
+    }
+
+    dispose() {
+        this.clearSkybox();
+        this.clearImage();
+    }
+}
+
+//
+
+const SkyboxName = 'background-skybox';
+
+type CubeAssets = { [k in keyof CubeFaces]: Asset };
+
+function getCubeAssets(assetManager: AssetManager, faces: SkyboxProps['faces']): CubeAssets {
+    if (faces.name === 'urls') {
+        return {
+            nx: Asset.getUrlAsset(assetManager, faces.params.nx),
+            ny: Asset.getUrlAsset(assetManager, faces.params.ny),
+            nz: Asset.getUrlAsset(assetManager, faces.params.nz),
+            px: Asset.getUrlAsset(assetManager, faces.params.px),
+            py: Asset.getUrlAsset(assetManager, faces.params.py),
+            pz: Asset.getUrlAsset(assetManager, faces.params.pz),
+        };
+    } else {
+        return {
+            nx: faces.params.nx!,
+            ny: faces.params.ny!,
+            nz: faces.params.nz!,
+            px: faces.params.px!,
+            py: faces.params.py!,
+            pz: faces.params.pz!,
+        };
+    }
+}
+
+function getCubeFaces(assetManager: AssetManager, cubeAssets: CubeAssets): CubeFaces {
+    const resolve = (asset: Asset) => {
+        return assetManager.resolve(asset, 'binary').run().then(a => new Blob([a.data]));
+    };
+
+    return {
+        nx: resolve(cubeAssets.nx),
+        ny: resolve(cubeAssets.ny),
+        nz: resolve(cubeAssets.nz),
+        px: resolve(cubeAssets.px),
+        py: resolve(cubeAssets.py),
+        pz: resolve(cubeAssets.pz),
+    };
+}
+
+function getSkyboxHash(faces: SkyboxProps['faces']) {
+    if (faces.name === 'urls') {
+        return `${SkyboxName}_${faces.params.nx}|${faces.params.ny}|${faces.params.nz}|${faces.params.px}|${faces.params.py}|${faces.params.pz}`;
+    } else {
+        return `${SkyboxName}_${faces.params.nx?.id}|${faces.params.ny?.id}|${faces.params.nz?.id}|${faces.params.px?.id}|${faces.params.py?.id}|${faces.params.pz?.id}`;
+    }
+}
+
+function areSkyboxTexturePropsEqual(facesA: SkyboxProps['faces'], facesB: SkyboxProps['faces']) {
+    return getSkyboxHash(facesA) === getSkyboxHash(facesB);
+}
+
+function getSkyboxTexture(ctx: WebGLContext, assetManager: AssetManager, faces: SkyboxProps['faces'], onload?: (errored?: boolean) => void): { texture: Texture, assets: Asset[] } {
+    const cubeAssets = getCubeAssets(assetManager, faces);
+    const cubeFaces = getCubeFaces(assetManager, cubeAssets);
+    const assets = [cubeAssets.nx, cubeAssets.ny, cubeAssets.nz, cubeAssets.px, cubeAssets.py, cubeAssets.pz];
+    const texture = ctx.resources.cubeTexture(cubeFaces, false, onload);
+    return { texture, assets };
+}
+
+//
+
+const ImageName = 'background-image';
+
+function getImageHash(source: ImageProps['source']) {
+    if (source.name === 'url') {
+        return `${ImageName}_${source.params}`;
+    } else {
+        return `${ImageName}_${source.params?.id}`;
+    }
+}
+
+function areImageTexturePropsEqual(sourceA: ImageProps['source'], sourceB: ImageProps['source']) {
+    return getImageHash(sourceA) === getImageHash(sourceB);
+}
+
+function getImageTexture(ctx: WebGLContext, assetManager: AssetManager, source: ImageProps['source'], onload?: (errored?: boolean) => void): { texture: Texture, asset: Asset } {
+    const texture = ctx.resources.texture('image-uint8', 'rgba', 'ubyte', 'linear');
+    const img = new Image();
+    img.onload = () => {
+        texture.load(img);
+        onload?.();
+    };
+    img.onerror = () => {
+        onload?.(true);
+    };
+    const asset = source.name === 'url'
+        ? Asset.getUrlAsset(assetManager, source.params)
+        : source.params!;
+    assetManager.resolve(asset, 'binary').run().then(a => {
+        const blob = new Blob([a.data]);
+        img.src = URL.createObjectURL(blob);
+    });
+    return { texture, asset };
+}
+
+//
+
+const BackgroundSchema = {
+    drawCount: ValueSpec('number'),
+    instanceCount: ValueSpec('number'),
+    aPosition: AttributeSpec('float32', 2, 0),
+    tSkybox: TextureSpec('texture', 'rgba', 'ubyte', 'linear'),
+    tImage: TextureSpec('texture', 'rgba', 'ubyte', 'linear'),
+    uImageScale: UniformSpec('v2'),
+    uImageOffset: UniformSpec('v2'),
+    uTexSize: UniformSpec('v2'),
+    uViewport: UniformSpec('v4'),
+    uViewportAdjusted: UniformSpec('b'),
+    uViewDirectionProjectionInverse: UniformSpec('m4'),
+    uGradientColorA: UniformSpec('v3'),
+    uGradientColorB: UniformSpec('v3'),
+    uGradientRatio: UniformSpec('f'),
+    uOpacity: UniformSpec('f'),
+    uSaturation: UniformSpec('f'),
+    uLightness: UniformSpec('f'),
+    dVariant: DefineSpec('string', ['skybox', 'image', 'verticalGradient', 'horizontalGradient', 'radialGradient']),
+};
+const SkyboxShaderCode = ShaderCode('background', background_vert, background_frag);
+type BackgroundRenderable = ComputeRenderable<Values<typeof BackgroundSchema>>
+
+function getBackgroundRenderable(ctx: WebGLContext, width: number, height: number): BackgroundRenderable {
+    const values: Values<typeof BackgroundSchema> = {
+        drawCount: ValueCell.create(6),
+        instanceCount: ValueCell.create(1),
+        aPosition: ValueCell.create(QuadPositions),
+        tSkybox: ValueCell.create(createNullTexture()),
+        tImage: ValueCell.create(createNullTexture()),
+        uImageScale: ValueCell.create(Vec2()),
+        uImageOffset: ValueCell.create(Vec2()),
+        uTexSize: ValueCell.create(Vec2.create(width, height)),
+        uViewport: ValueCell.create(Vec4()),
+        uViewportAdjusted: ValueCell.create(true),
+        uViewDirectionProjectionInverse: ValueCell.create(Mat4()),
+        uGradientColorA: ValueCell.create(Vec3()),
+        uGradientColorB: ValueCell.create(Vec3()),
+        uGradientRatio: ValueCell.create(0.5),
+        uOpacity: ValueCell.create(1),
+        uSaturation: ValueCell.create(0),
+        uLightness: ValueCell.create(0),
+        dVariant: ValueCell.create('skybox'),
+    };
+
+    const schema = { ...BackgroundSchema };
+    const renderItem = createComputeRenderItem(ctx, 'triangles', SkyboxShaderCode, schema, values);
+
+    return createComputeRenderable(renderItem, values);
+}

+ 49 - 45
src/mol-canvas3d/passes/draw.ts

@@ -21,10 +21,11 @@ import { AntialiasingPass, PostprocessingPass, PostprocessingProps } from './pos
 import { MarkingPass, MarkingProps } from './marking';
 import { CopyRenderable, createCopyRenderable } from '../../mol-gl/compute/util';
 import { isTimingMode } from '../../mol-util/debug';
+import { AssetManager } from '../../mol-util/assets';
 
 type Props = {
-    postprocessing: PostprocessingProps
-    marking: MarkingProps
+    postprocessing: PostprocessingProps;
+    marking: MarkingProps;
     transparentBackground: boolean;
 }
 
@@ -50,7 +51,7 @@ export class DrawPass {
     private copyFboTarget: CopyRenderable;
     private copyFboPostprocessing: CopyRenderable;
 
-    private wboit: WboitPass | undefined;
+    private readonly wboit: WboitPass | undefined;
     private readonly marking: MarkingPass;
     readonly postprocessing: PostprocessingPass;
     private readonly antialiasing: AntialiasingPass;
@@ -59,11 +60,10 @@ export class DrawPass {
         return !!this.wboit?.supported;
     }
 
-    constructor(private webgl: WebGLContext, width: number, height: number, enableWboit: boolean) {
+    constructor(private webgl: WebGLContext, assetManager: AssetManager, width: number, height: number, enableWboit: boolean) {
         const { extensions, resources, isWebGL2 } = webgl;
 
         this.drawTarget = createNullRenderTarget(webgl.gl);
-
         this.colorTarget = webgl.createRenderTarget(width, height, true, 'uint8', 'linear');
         this.packedDepth = !extensions.depthTexture;
 
@@ -79,7 +79,7 @@ export class DrawPass {
 
         this.wboit = enableWboit ? new WboitPass(webgl, width, height) : undefined;
         this.marking = new MarkingPass(webgl, width, height);
-        this.postprocessing = new PostprocessingPass(webgl, this);
+        this.postprocessing = new PostprocessingPass(webgl, assetManager, this);
         this.antialiasing = new AntialiasingPass(webgl, this);
 
         this.copyFboTarget = createCopyRenderable(webgl, this.colorTarget.texture);
@@ -120,14 +120,13 @@ export class DrawPass {
     private _renderWboit(renderer: Renderer, camera: ICamera, scene: Scene, transparentBackground: boolean, postprocessingProps: PostprocessingProps) {
         if (!this.wboit?.supported) throw new Error('expected wboit to be supported');
 
-        this.colorTarget.bind();
+        this.depthTextureOpaque.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
         renderer.clear(true);
 
         // render opaque primitives
-        this.depthTextureOpaque.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
-        this.colorTarget.bind();
-        renderer.clearDepth();
-        renderer.renderWboitOpaque(scene.primitives, camera, null);
+        if (scene.hasOpaque) {
+            renderer.renderWboitOpaque(scene.primitives, camera, null);
+        }
 
         if (PostprocessingPass.isEnabled(postprocessingProps)) {
             if (PostprocessingPass.isOutlineEnabled(postprocessingProps)) {
@@ -165,14 +164,17 @@ export class DrawPass {
         if (toDrawingBuffer) {
             this.drawTarget.bind();
         } else {
-            this.colorTarget.bind();
             if (!this.packedDepth) {
                 this.depthTextureOpaque.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
+            } else {
+                this.colorTarget.bind();
             }
         }
 
         renderer.clear(true);
-        renderer.renderBlendedOpaque(scene.primitives, camera, null);
+        if (scene.hasOpaque) {
+            renderer.renderBlendedOpaque(scene.primitives, camera, null);
+        }
 
         if (!toDrawingBuffer) {
             // do a depth pass if not rendering to drawing buffer and
@@ -235,7 +237,7 @@ export class DrawPass {
         }
     }
 
-    private _render(renderer: Renderer, camera: ICamera, scene: Scene, helper: Helper, toDrawingBuffer: boolean, props: Props) {
+    private _render(renderer: Renderer, camera: ICamera, scene: Scene, helper: Helper, toDrawingBuffer: boolean, transparentBackground: boolean, props: Props) {
         const volumeRendering = scene.volumes.renderables.length > 0;
         const postprocessingEnabled = PostprocessingPass.isEnabled(props.postprocessing);
         const antialiasingEnabled = AntialiasingPass.isEnabled(props.postprocessing);
@@ -245,54 +247,52 @@ export class DrawPass {
         renderer.setViewport(x, y, width, height);
         renderer.update(camera);
 
-        if (props.transparentBackground && !antialiasingEnabled && toDrawingBuffer) {
+        if (transparentBackground && !antialiasingEnabled && toDrawingBuffer) {
             this.drawTarget.bind();
             renderer.clear(false);
         }
 
         if (this.wboitEnabled) {
-            this._renderWboit(renderer, camera, scene, props.transparentBackground, props.postprocessing);
-        } else {
-            this._renderBlended(renderer, camera, scene, !volumeRendering && !postprocessingEnabled && !antialiasingEnabled && toDrawingBuffer, props.transparentBackground, props.postprocessing);
-        }
-
-        if (postprocessingEnabled) {
-            this.postprocessing.target.bind();
-        } else if (!toDrawingBuffer || volumeRendering || this.wboitEnabled) {
-            this.colorTarget.bind();
+            this._renderWboit(renderer, camera, scene, transparentBackground, props.postprocessing);
         } else {
-            this.drawTarget.bind();
+            this._renderBlended(renderer, camera, scene, !volumeRendering && !postprocessingEnabled && !antialiasingEnabled && toDrawingBuffer, transparentBackground, props.postprocessing);
         }
 
-        if (markingEnabled) {
-            if (scene.markerAverage > 0) {
-                const markingDepthTest = props.marking.ghostEdgeStrength < 1;
-                if (markingDepthTest && scene.markerAverage !== 1) {
-                    this.marking.depthTarget.bind();
-                    renderer.clear(false, true);
-                    renderer.renderMarkingDepth(scene.primitives, camera, null);
-                }
+        const target = postprocessingEnabled
+            ? this.postprocessing.target
+            : !toDrawingBuffer || volumeRendering || this.wboitEnabled
+                ? this.colorTarget
+                : this.drawTarget;
 
-                this.marking.maskTarget.bind();
+        if (markingEnabled && scene.markerAverage > 0) {
+            const markingDepthTest = props.marking.ghostEdgeStrength < 1;
+            if (markingDepthTest && scene.markerAverage !== 1) {
+                this.marking.depthTarget.bind();
                 renderer.clear(false, true);
-                renderer.renderMarkingMask(scene.primitives, camera, markingDepthTest ? this.marking.depthTarget.texture : null);
-
-                this.marking.update(props.marking);
-                this.marking.render(camera.viewport, postprocessingEnabled ? this.postprocessing.target : this.colorTarget);
+                renderer.renderMarkingDepth(scene.primitives, camera, null);
             }
+
+            this.marking.maskTarget.bind();
+            renderer.clear(false, true);
+            renderer.renderMarkingMask(scene.primitives, camera, markingDepthTest ? this.marking.depthTarget.texture : null);
+
+            this.marking.update(props.marking);
+            this.marking.render(camera.viewport, target);
+        } else {
+            target.bind();
         }
 
         if (helper.debug.isEnabled) {
             helper.debug.syncVisibility();
-            renderer.renderBlended(helper.debug.scene, camera, null);
+            renderer.renderBlended(helper.debug.scene, camera);
         }
         if (helper.handle.isEnabled) {
-            renderer.renderBlended(helper.handle.scene, camera, null);
+            renderer.renderBlended(helper.handle.scene, camera);
         }
         if (helper.camera.isEnabled) {
             helper.camera.update(camera);
             renderer.update(helper.camera.camera);
-            renderer.renderBlended(helper.camera.scene, helper.camera.camera, null);
+            renderer.renderBlended(helper.camera.scene, helper.camera.camera);
         }
 
         if (antialiasingEnabled) {
@@ -314,15 +314,19 @@ export class DrawPass {
     render(ctx: RenderContext, props: Props, toDrawingBuffer: boolean) {
         if (isTimingMode) this.webgl.timer.mark('DrawPass.render');
         const { renderer, camera, scene, helper } = ctx;
-        renderer.setTransparentBackground(props.transparentBackground);
+
+        this.postprocessing.setTransparentBackground(props.transparentBackground);
+        const transparentBackground = props.transparentBackground || this.postprocessing.background.isEnabled(props.postprocessing.background);
+
+        renderer.setTransparentBackground(transparentBackground);
         renderer.setDrawingBufferSize(this.colorTarget.getWidth(), this.colorTarget.getHeight());
         renderer.setPixelRatio(this.webgl.pixelRatio);
 
         if (StereoCamera.is(camera)) {
-            this._render(renderer, camera.left, scene, helper, toDrawingBuffer, props);
-            this._render(renderer, camera.right, scene, helper, toDrawingBuffer, props);
+            this._render(renderer, camera.left, scene, helper, toDrawingBuffer, transparentBackground, props);
+            this._render(renderer, camera.right, scene, helper, toDrawingBuffer, transparentBackground, props);
         } else {
-            this._render(renderer, camera, scene, helper, toDrawingBuffer, props);
+            this._render(renderer, camera, scene, helper, toDrawingBuffer, transparentBackground, props);
         }
         if (isTimingMode) this.webgl.timer.markEnd('DrawPass.render');
     }

+ 2 - 2
src/mol-canvas3d/passes/fxaa.ts

@@ -44,8 +44,8 @@ export class FxaaPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.clearColor(0, 0, 0, 1);
         gl.clear(gl.COLOR_BUFFER_BIT);

+ 12 - 3
src/mol-canvas3d/passes/image.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -18,6 +18,7 @@ import { PixelData } from '../../mol-util/image';
 import { Helper } from '../helper/helper';
 import { CameraHelper, CameraHelperParams } from '../helper/camera-helper';
 import { MarkingParams } from './marking';
+import { AssetManager } from '../../mol-util/assets';
 
 export const ImageParams = {
     transparentBackground: PD.Boolean(false),
@@ -47,10 +48,10 @@ export class ImagePass {
     get width() { return this._width; }
     get height() { return this._height; }
 
-    constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private camera: Camera, helper: Helper, enableWboit: boolean, props: Partial<ImageProps>) {
+    constructor(private webgl: WebGLContext, assetManager: AssetManager, private renderer: Renderer, private scene: Scene, private camera: Camera, helper: Helper, enableWboit: boolean, props: Partial<ImageProps>) {
         this.props = { ...PD.getDefaultValues(ImageParams), ...props };
 
-        this.drawPass = new DrawPass(webgl, 128, 128, enableWboit);
+        this.drawPass = new DrawPass(webgl, assetManager, 128, 128, enableWboit);
         this.multiSamplePass = new MultiSamplePass(webgl, this.drawPass);
         this.multiSampleHelper = new MultiSampleHelper(this.multiSamplePass);
 
@@ -63,6 +64,14 @@ export class ImagePass {
         this.setSize(1024, 768);
     }
 
+    updateBackground() {
+        return new Promise<void>(resolve => {
+            this.drawPass.postprocessing.background.update(this.camera, this.props.postprocessing.background, () => {
+                resolve();
+            });
+        });
+    }
+
     setSize(width: number, height: number) {
         if (width === this._width && height === this._height) return;
 

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

@@ -64,8 +64,8 @@ export class MarkingPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.clearColor(0, 0, 0, 0);
         gl.clear(gl.COLOR_BUFFER_BIT);
@@ -82,8 +82,8 @@ export class MarkingPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
     }
 
     setSize(width: number, height: number) {

+ 10 - 10
src/mol-canvas3d/passes/multi-sample.ts

@@ -176,8 +176,8 @@ export class MultiSamplePass {
             state.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
             state.disable(gl.DEPTH_TEST);
             state.depthMask(false);
-            gl.viewport(x, y, width, height);
-            gl.scissor(x, y, width, height);
+            state.viewport(x, y, width, height);
+            state.scissor(x, y, width, height);
             if (i === 0) {
                 state.clearColor(0, 0, 0, 0);
                 gl.clear(gl.COLOR_BUFFER_BIT);
@@ -192,8 +192,8 @@ export class MultiSamplePass {
         compose.update();
 
         this.bindOutputTarget(toDrawingBuffer);
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.disable(gl.BLEND);
         compose.render();
@@ -231,8 +231,8 @@ export class MultiSamplePass {
             state.disable(gl.BLEND);
             state.disable(gl.DEPTH_TEST);
             state.depthMask(false);
-            gl.viewport(x, y, width, height);
-            gl.scissor(x, y, width, height);
+            state.viewport(x, y, width, height);
+            state.scissor(x, y, width, height);
             compose.render();
             sampleIndex += 1;
         } else {
@@ -267,8 +267,8 @@ export class MultiSamplePass {
                 state.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
                 state.disable(gl.DEPTH_TEST);
                 state.depthMask(false);
-                gl.viewport(x, y, width, height);
-                gl.scissor(x, y, width, height);
+                state.viewport(x, y, width, height);
+                state.scissor(x, y, width, height);
                 if (sampleIndex === 0) {
                     state.clearColor(0, 0, 0, 0);
                     gl.clear(gl.COLOR_BUFFER_BIT);
@@ -283,8 +283,8 @@ export class MultiSamplePass {
         drawPass.postprocessing.setOcclusionOffset(0, 0);
 
         this.bindOutputTarget(toDrawingBuffer);
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         const accumulationWeight = sampleIndex * sampleWeight;
         if (accumulationWeight > 0) {

+ 3 - 2
src/mol-canvas3d/passes/passes.ts

@@ -8,15 +8,16 @@ import { DrawPass } from './draw';
 import { PickPass } from './pick';
 import { MultiSamplePass } from './multi-sample';
 import { WebGLContext } from '../../mol-gl/webgl/context';
+import { AssetManager } from '../../mol-util/assets';
 
 export class Passes {
     readonly draw: DrawPass;
     readonly pick: PickPass;
     readonly multiSample: MultiSamplePass;
 
-    constructor(private webgl: WebGLContext, attribs: Partial<{ pickScale: number, enableWboit: boolean }> = {}) {
+    constructor(private webgl: WebGLContext, assetManager: AssetManager, attribs: Partial<{ pickScale: number, enableWboit: boolean }> = {}) {
         const { gl } = webgl;
-        this.draw = new DrawPass(webgl, gl.drawingBufferWidth, gl.drawingBufferHeight, attribs.enableWboit || false);
+        this.draw = new DrawPass(webgl, assetManager, gl.drawingBufferWidth, gl.drawingBufferHeight, attribs.enableWboit || false);
         this.pick = new PickPass(webgl, this.draw, attribs.pickScale || 0.25);
         this.multiSample = new MultiSamplePass(webgl, this.draw);
     }

+ 54 - 24
src/mol-canvas3d/passes/postprocessing.ts

@@ -28,6 +28,8 @@ import { Color } from '../../mol-util/color';
 import { FxaaParams, FxaaPass } from './fxaa';
 import { SmaaParams, SmaaPass } from './smaa';
 import { isTimingMode } from '../../mol-util/debug';
+import { BackgroundParams, BackgroundPass } from './background';
+import { AssetManager } from '../../mol-util/assets';
 
 const OutlinesSchema = {
     ...QuadSchema,
@@ -91,7 +93,7 @@ function getSsaoRenderable(ctx: WebGLContext, depthTexture: Texture): SsaoRender
         ...QuadValues,
         tDepth: ValueCell.create(depthTexture),
 
-        uSamples: ValueCell.create([0.0, 0.0, 1.0]),
+        uSamples: ValueCell.create(getSamples(32)),
         dNSamples: ValueCell.create(32),
 
         uProjection: ValueCell.create(Mat4.identity()),
@@ -138,7 +140,7 @@ function getSsaoBlurRenderable(ctx: WebGLContext, ssaoDepthTexture: Texture, dir
         tSsaoDepth: ValueCell.create(ssaoDepthTexture),
         uTexSize: ValueCell.create(Vec2.create(ssaoDepthTexture.getWidth(), ssaoDepthTexture.getHeight())),
 
-        uKernel: ValueCell.create([0.0]),
+        uKernel: ValueCell.create(getBlurKernel(15)),
         dOcclusionKernelSize: ValueCell.create(15),
 
         uBlurDirectionX: ValueCell.create(direction === 'horizontal' ? 1 : 0),
@@ -171,15 +173,26 @@ function getBlurKernel(kernelSize: number): number[] {
     return kernel;
 }
 
-function getSamples(vectorSamples: Vec3[], nSamples: number): number[] {
+const RandomHemisphereVector: Vec3[] = [];
+for (let i = 0; i < 256; i++) {
+    const v = Vec3();
+    v[0] = Math.random() * 2.0 - 1.0;
+    v[1] = Math.random() * 2.0 - 1.0;
+    v[2] = Math.random();
+    Vec3.normalize(v, v);
+    Vec3.scale(v, v, Math.random());
+    RandomHemisphereVector.push(v);
+}
+
+function getSamples(nSamples: number): number[] {
     const samples = [];
     for (let i = 0; i < nSamples; i++) {
         let scale = (i * i + 2.0 * i + 1) / (nSamples * nSamples);
         scale = 0.1 + scale * (1.0 - 0.1);
 
-        samples.push(vectorSamples[i][0] * scale);
-        samples.push(vectorSamples[i][1] * scale);
-        samples.push(vectorSamples[i][2] * scale);
+        samples.push(RandomHemisphereVector[i][0] * scale);
+        samples.push(RandomHemisphereVector[i][1] * scale);
+        samples.push(RandomHemisphereVector[i][2] * scale);
     }
 
     return samples;
@@ -274,12 +287,13 @@ export const PostprocessingParams = {
         smaa: PD.Group(SmaaParams),
         off: PD.Group({})
     }, { options: [['fxaa', 'FXAA'], ['smaa', 'SMAA'], ['off', 'Off']], description: 'Smooth pixel edges' }),
+    background: PD.Group(BackgroundParams, { isFlat: true }),
 };
 export type PostprocessingProps = PD.Values<typeof PostprocessingParams>
 
 export class PostprocessingPass {
     static isEnabled(props: PostprocessingProps) {
-        return props.occlusion.name === 'on' || props.outline.name === 'on';
+        return props.occlusion.name === 'on' || props.outline.name === 'on' || props.background.variant.name !== 'off';
     }
 
     static isOutlineEnabled(props: PostprocessingProps) {
@@ -291,7 +305,6 @@ export class PostprocessingPass {
     private readonly outlinesTarget: RenderTarget;
     private readonly outlinesRenderable: OutlinesRenderable;
 
-    private readonly randomHemisphereVector: Vec3[];
     private readonly ssaoFramebuffer: Framebuffer;
     private readonly ssaoBlurFirstPassFramebuffer: Framebuffer;
     private readonly ssaoBlurSecondPassFramebuffer: Framebuffer;
@@ -318,7 +331,10 @@ export class PostprocessingPass {
         return Math.min(1, 1 / this.webgl.pixelRatio) * this.downsampleFactor;
     }
 
-    constructor(private webgl: WebGLContext, private drawPass: DrawPass) {
+    private readonly bgColor = Vec3();
+    readonly background: BackgroundPass;
+
+    constructor(private readonly webgl: WebGLContext, assetManager: AssetManager, private readonly drawPass: DrawPass) {
         const { colorTarget, depthTextureTransparent, depthTextureOpaque } = drawPass;
         const width = colorTarget.getWidth();
         const height = colorTarget.getHeight();
@@ -334,16 +350,6 @@ export class PostprocessingPass {
         this.outlinesTarget = webgl.createRenderTarget(width, height, false);
         this.outlinesRenderable = getOutlinesRenderable(webgl, depthTextureOpaque, depthTextureTransparent);
 
-        this.randomHemisphereVector = [];
-        for (let i = 0; i < 256; i++) {
-            const v = Vec3();
-            v[0] = Math.random() * 2.0 - 1.0;
-            v[1] = Math.random() * 2.0 - 1.0;
-            v[2] = Math.random();
-            Vec3.normalize(v, v);
-            Vec3.scale(v, v, Math.random());
-            this.randomHemisphereVector.push(v);
-        }
         this.ssaoFramebuffer = webgl.resources.framebuffer();
         this.ssaoBlurFirstPassFramebuffer = webgl.resources.framebuffer();
         this.ssaoBlurSecondPassFramebuffer = webgl.resources.framebuffer();
@@ -368,6 +374,8 @@ export class PostprocessingPass {
         this.ssaoBlurFirstPassRenderable = getSsaoBlurRenderable(webgl, this.ssaoDepthTexture, 'horizontal');
         this.ssaoBlurSecondPassRenderable = getSsaoBlurRenderable(webgl, this.ssaoDepthBlurProxyTexture, 'vertical');
         this.renderable = getPostprocessingRenderable(webgl, colorTarget.texture, depthTextureOpaque, depthTextureTransparent, this.outlinesTarget.texture, this.ssaoDepthTexture);
+
+        this.background = new BackgroundPass(webgl, assetManager, width, height);
     }
 
     setSize(width: number, height: number) {
@@ -391,6 +399,8 @@ export class PostprocessingPass {
             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));
+
+            this.background.setSize(width, height);
         }
     }
 
@@ -440,7 +450,7 @@ export class PostprocessingPass {
                 needsUpdateSsao = true;
 
                 this.nSamples = props.occlusion.params.samples;
-                ValueCell.update(this.ssaoRenderable.values.uSamples, getSamples(this.randomHemisphereVector, this.nSamples));
+                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));
@@ -538,8 +548,8 @@ export class PostprocessingPass {
         state.depthMask(false);
 
         const { x, y, width, height } = camera.viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
     }
 
     private occlusionOffset: [x: number, y: number] = [0, 0];
@@ -549,6 +559,11 @@ export class PostprocessingPass {
         ValueCell.update(this.renderable.values.uOcclusionOffset, Vec2.set(this.renderable.values.uOcclusionOffset.ref.value, x, y));
     }
 
+    private transparentBackground = false;
+    setTransparentBackground(value: boolean) {
+        this.transparentBackground = value;
+    }
+
     render(camera: ICamera, toDrawingBuffer: boolean, transparentBackground: boolean, backgroundColor: Color, props: PostprocessingProps) {
         if (isTimingMode) this.webgl.timer.mark('PostprocessingPass.render');
         this.updateState(camera, transparentBackground, backgroundColor, props);
@@ -583,8 +598,23 @@ export class PostprocessingPass {
         }
 
         const { gl, state } = this.webgl;
-        state.clearColor(0, 0, 0, 1);
-        gl.clear(gl.COLOR_BUFFER_BIT);
+
+        this.background.update(camera, props.background);
+        if (this.background.isEnabled(props.background)) {
+            if (this.transparentBackground) {
+                state.clearColor(0, 0, 0, 0);
+            } else {
+                Color.toVec3Normalized(this.bgColor, backgroundColor);
+                state.clearColor(this.bgColor[0], this.bgColor[1], this.bgColor[2], 1);
+            }
+            gl.clear(gl.COLOR_BUFFER_BIT);
+            state.enable(gl.BLEND);
+            state.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+            this.background.render();
+        } else {
+            state.clearColor(0, 0, 0, 1);
+            gl.clear(gl.COLOR_BUFFER_BIT);
+        }
 
         this.renderable.render();
         if (isTimingMode) this.webgl.timer.markEnd('PostprocessingPass.render');

+ 2 - 2
src/mol-canvas3d/passes/smaa.ts

@@ -71,8 +71,8 @@ export class SmaaPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.clearColor(0, 0, 0, 1);
         gl.clear(gl.COLOR_BUFFER_BIT);

+ 11 - 1
src/mol-canvas3d/passes/wboit.ts

@@ -18,6 +18,8 @@ import { evaluateWboit_frag } from '../../mol-gl/shader/evaluate-wboit.frag';
 import { Framebuffer } from '../../mol-gl/webgl/framebuffer';
 import { Vec2 } from '../../mol-math/linear-algebra';
 import { isDebugMode, isTimingMode } from '../../mol-util/debug';
+import { isWebGL2 } from '../../mol-gl/webgl/compat';
+import { Renderbuffer } from '../../mol-gl/webgl/renderbuffer';
 
 const EvaluateWboitSchema = {
     ...QuadSchema,
@@ -50,6 +52,7 @@ export class WboitPass {
     private readonly framebuffer: Framebuffer;
     private readonly textureA: Texture;
     private readonly textureB: Texture;
+    private readonly depthRenderbuffer: Renderbuffer;
 
     private _supported = false;
     get supported() {
@@ -87,6 +90,7 @@ export class WboitPass {
         if (width !== w || height !== h) {
             this.textureA.define(width, height);
             this.textureB.define(width, height);
+            this.depthRenderbuffer.setSize(width, height);
             ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
         }
     }
@@ -106,6 +110,8 @@ export class WboitPass {
 
         this.textureA.attachFramebuffer(this.framebuffer, 'color0');
         this.textureB.attachFramebuffer(this.framebuffer, 'color1');
+
+        this.depthRenderbuffer.attachFramebuffer(this.framebuffer);
     }
 
     static isSupported(webgl: WebGLContext) {
@@ -128,7 +134,7 @@ export class WboitPass {
     constructor(private webgl: WebGLContext, width: number, height: number) {
         if (!WboitPass.isSupported(webgl)) return;
 
-        const { resources } = webgl;
+        const { resources, gl } = webgl;
 
         this.textureA = resources.texture('image-float32', 'rgba', 'float', 'nearest');
         this.textureA.define(width, height);
@@ -136,6 +142,10 @@ export class WboitPass {
         this.textureB = resources.texture('image-float32', 'rgba', 'float', 'nearest');
         this.textureB.define(width, height);
 
+        this.depthRenderbuffer = isWebGL2(gl)
+            ? resources.renderbuffer('depth32f', 'depth', width, height)
+            : resources.renderbuffer('depth16', 'depth', width, height);
+
         this.renderable = getEvaluateWboitRenderable(webgl, this.textureA, this.textureB);
         this.framebuffer = resources.framebuffer();
 

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

@@ -319,8 +319,8 @@ export function calcTextureMeshColorSmoothing(input: ColorSmoothingInput, resolu
 
     if (isTimingMode) webgl.timer.mark('ColorAccumulate.render');
     setAccumulateDefaults(webgl);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     ValueCell.update(uCurrentY, 0);
     let currCol = 0;
@@ -336,8 +336,8 @@ export function calcTextureMeshColorSmoothing(input: ColorSmoothingInput, resolu
         // console.log({ i, currX, currY });
         ValueCell.update(uCurrentX, currX);
         ValueCell.update(uCurrentSlice, i);
-        gl.viewport(currX, currY, dx, dy);
-        gl.scissor(currX, currY, dx, dy);
+        state.viewport(currX, currY, dx, dy);
+        state.scissor(currX, currY, dx, dy);
         accumulateRenderable.render();
         ++currCol;
         currX += dx;
@@ -371,8 +371,8 @@ export function calcTextureMeshColorSmoothing(input: ColorSmoothingInput, resolu
 
     setNormalizeDefaults(webgl);
     texture.attachFramebuffer(framebuffer, 0);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     normalizeRenderable.render();
     if (isTimingMode) webgl.timer.markEnd('ColorNormalize.render');

+ 2 - 2
src/mol-gl/compute/grid3d.ts

@@ -225,8 +225,8 @@ export function createGrid3dComputeRenderable<S extends RenderableSchema, P, CS>
 
 function resetGl(webgl: WebGLContext, w: number) {
     const { gl, state } = webgl;
-    gl.viewport(0, 0, w, w);
-    gl.scissor(0, 0, w, w);
+    state.viewport(0, 0, w, w);
+    state.scissor(0, 0, w, w);
     state.disable(gl.SCISSOR_TEST);
     state.disable(gl.BLEND);
     state.disable(gl.DEPTH_TEST);

+ 7 - 7
src/mol-gl/compute/histogram-pyramid/reduction.ts

@@ -122,7 +122,7 @@ export interface HistogramPyramid {
 
 export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture, scale: Vec2, gridTexDim: Vec3): HistogramPyramid {
     if (isTimingMode) ctx.timer.mark('createHistogramPyramid');
-    const { gl } = ctx;
+    const { gl, state } = ctx;
     const w = inputTexture.getWidth();
     const h = inputTexture.getHeight();
 
@@ -146,7 +146,7 @@ export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture,
     const framebuffer = getFramebuffer('pyramid', ctx);
     pyramidTex.attachFramebuffer(framebuffer, 0);
 
-    gl.viewport(0, 0, maxSizeX, maxSizeY);
+    state.viewport(0, 0, maxSizeX, maxSizeY);
     if (isWebGL2(gl)) {
         gl.clearBufferiv(gl.COLOR, 0, [0, 0, 0, 0]);
     } else {
@@ -157,7 +157,7 @@ export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture,
     for (let i = 0; i < levels; ++i) levelTexturesFramebuffers.push(getLevelTextureFramebuffer(ctx, i));
 
     const renderable = getHistopyramidReductionRenderable(ctx, inputTexture, levelTexturesFramebuffers[0].texture);
-    ctx.state.currentRenderItemId = -1;
+    state.currentRenderItemId = -1;
     setRenderingDefaults(ctx);
 
     let offset = 0;
@@ -176,15 +176,15 @@ export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture,
             ValueCell.update(renderable.values.tPreviousLevel, levelTexturesFramebuffers[levels - i].texture);
             renderable.update();
         }
-        ctx.state.currentRenderItemId = -1;
-        gl.viewport(0, 0, size, size);
-        gl.scissor(0, 0, size, size);
+        state.currentRenderItemId = -1;
+        state.viewport(0, 0, size, size);
+        state.scissor(0, 0, size, size);
         if (isWebGL2(gl)) {
             gl.clearBufferiv(gl.COLOR, 0, [0, 0, 0, 0]);
         } else {
             gl.clear(gl.COLOR_BUFFER_BIT);
         }
-        gl.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
+        state.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
         renderable.render();
 
         pyramidTex.bind(0);

+ 2 - 2
src/mol-gl/compute/histogram-pyramid/sum.ts

@@ -68,7 +68,7 @@ const sumInts = new Int32Array(4);
 
 export function getHistopyramidSum(ctx: WebGLContext, pyramidTopTexture: Texture) {
     if (isTimingMode) ctx.timer.mark('getHistopyramidSum');
-    const { gl, resources } = ctx;
+    const { gl, state, resources } = ctx;
 
     const renderable = getHistopyramidSumRenderable(ctx, pyramidTopTexture);
     ctx.state.currentRenderItemId = -1;
@@ -89,7 +89,7 @@ export function getHistopyramidSum(ctx: WebGLContext, pyramidTopTexture: Texture
 
     setRenderingDefaults(ctx);
 
-    gl.viewport(0, 0, 1, 1);
+    state.viewport(0, 0, 1, 1);
     renderable.render();
     gl.finish();
 

+ 4 - 4
src/mol-gl/compute/marching-cubes/active-voxels.ts

@@ -85,7 +85,7 @@ function setRenderingDefaults(ctx: WebGLContext) {
 
 export function calcActiveVoxels(ctx: WebGLContext, volumeData: Texture, gridDim: Vec3, gridTexDim: Vec3, isoValue: number, gridScale: Vec2) {
     if (isTimingMode) ctx.timer.mark('calcActiveVoxels');
-    const { gl, resources } = ctx;
+    const { gl, state, resources } = ctx;
     const width = volumeData.getWidth();
     const height = volumeData.getHeight();
 
@@ -106,10 +106,10 @@ export function calcActiveVoxels(ctx: WebGLContext, volumeData: Texture, gridDim
 
     activeVoxelsTex.attachFramebuffer(framebuffer, 0);
     setRenderingDefaults(ctx);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
-    gl.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
+    state.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
     renderable.render();
 
     // console.log('gridScale', gridScale, 'gridTexDim', gridTexDim, 'gridDim', gridDim);

+ 2 - 2
src/mol-gl/compute/marching-cubes/isosurface.ts

@@ -127,7 +127,7 @@ export function createIsosurfaceBuffers(ctx: WebGLContext, activeVoxelsBase: Tex
     if (!drawBuffers) throw new Error('need WebGL draw buffers');
 
     if (isTimingMode) ctx.timer.mark('createIsosurfaceBuffers');
-    const { gl, resources, extensions } = ctx;
+    const { gl, state, resources, extensions } = ctx;
     const { pyramidTex, height, levels, scale, count } = histogramPyramid;
     const width = pyramidTex.getWidth();
 
@@ -192,7 +192,7 @@ export function createIsosurfaceBuffers(ctx: WebGLContext, activeVoxelsBase: Tex
     ]);
 
     setRenderingDefaults(ctx);
-    gl.viewport(0, 0, width, height);
+    state.viewport(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     renderable.render();
 

+ 2 - 2
src/mol-gl/compute/util.ts

@@ -125,8 +125,8 @@ export function readAlphaTexture(ctx: WebGLContext, texture: Texture) {
     state.clearColor(0, 0, 0, 0);
     state.blendFunc(gl.ONE, gl.ONE);
     state.blendEquation(gl.FUNC_ADD);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     copy.render();
 

+ 13 - 9
src/mol-gl/renderer.ts

@@ -64,7 +64,7 @@ interface Renderer {
     renderDepthTransparent: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderMarkingDepth: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderMarkingMask: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
-    renderBlended: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
+    renderBlended: (group: Scene, camera: ICamera) => void
     renderBlendedOpaque: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderBlendedTransparent: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderBlendedVolume: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
@@ -359,8 +359,8 @@ namespace Renderer {
             state.colorMask(true, true, true, true);
 
             const { x, y, width, height } = viewport;
-            gl.viewport(x, y, width, height);
-            gl.scissor(x, y, width, height);
+            state.viewport(x, y, width, height);
+            state.scissor(x, y, width, height);
 
             globalUniformsNeedUpdate = true;
             state.currentRenderItemId = -1;
@@ -475,9 +475,13 @@ namespace Renderer {
             if (isTimingMode) ctx.timer.markEnd('Renderer.renderMarkingMask');
         };
 
-        const renderBlended = (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => {
-            renderBlendedOpaque(group, camera, depthTexture);
-            renderBlendedTransparent(group, camera, depthTexture);
+        const renderBlended = (scene: Scene, camera: ICamera) => {
+            if (scene.hasOpaque) {
+                renderBlendedOpaque(scene, camera, null);
+            }
+            if (scene.opacityAverage < 1) {
+                renderBlendedTransparent(scene, camera, null);
+            }
         };
 
         const renderBlendedOpaque = (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => {
@@ -591,7 +595,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.uBackgroundColor || r.values.dXrayShaded?.ref.value) {
+                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) {
                     renderObject(r, 'colorWboit', Flag.None);
                 }
             }
@@ -714,8 +718,8 @@ namespace Renderer {
                 }
             },
             setViewport: (x: number, y: number, width: number, height: number) => {
-                gl.viewport(x, y, width, height);
-                gl.scissor(x, y, width, height);
+                state.viewport(x, y, width, height);
+                state.scissor(x, y, width, height);
                 if (x !== viewport.x || y !== viewport.y || width !== viewport.width || height !== viewport.height) {
                     Viewport.set(viewport, x, y, width, height);
                     ValueCell.update(globalUniforms.uViewport, Vec4.set(globalUniforms.uViewport.ref.value, x, y, width, height));

+ 30 - 2
src/mol-gl/scene.ts

@@ -80,8 +80,12 @@ interface Scene extends Object3D {
     has: (o: GraphicsRenderObject) => boolean
     clear: () => void
     forEach: (callbackFn: (value: GraphicsRenderable, key: GraphicsRenderObject) => void) => void
+    /** Marker average of primitive renderables */
     readonly markerAverage: number
+    /** Opacity average of primitive renderables */
     readonly opacityAverage: number
+    /** Is `true` if any primitive renderable (possibly) has any opaque part */
+    readonly hasOpaque: boolean
 }
 
 namespace Scene {
@@ -103,6 +107,7 @@ namespace Scene {
 
         let markerAverage = 0;
         let opacityAverage = 0;
+        let hasOpaque = false;
 
         const object3d = Object3D.create();
         const { view, position, direction, up } = object3d;
@@ -160,7 +165,9 @@ namespace Scene {
             }
 
             renderables.sort(renderableSort);
+            markerAverage = calculateMarkerAverage();
             opacityAverage = calculateOpacityAverage();
+            hasOpaque = calculateHasOpaque();
             return true;
         }
 
@@ -182,7 +189,10 @@ namespace Scene {
             const newVisibleHash = computeVisibleHash();
             if (newVisibleHash !== visibleHash) {
                 boundingSphereVisibleDirty = true;
+                markerAverage = calculateMarkerAverage();
                 opacityAverage = calculateOpacityAverage();
+                hasOpaque = calculateHasOpaque();
+                visibleHash = newVisibleHash;
                 return true;
             } else {
                 return false;
@@ -212,12 +222,27 @@ namespace Scene {
                 // uAlpha is updated in "render" so we need to recompute it here
                 const alpha = clamp(p.values.alpha.ref.value * p.state.alphaFactor, 0, 1);
                 const xray = p.values.dXrayShaded?.ref.value ? 0.5 : 1;
-                opacityAverage += (1 - p.values.transparencyAverage.ref.value) * alpha * xray;
+                const fuzzy = p.values.dPointStyle?.ref.value === 'fuzzy' ? 0.5 : 1;
+                const text = p.values.dGeometryType.ref.value === 'text' ? 0.5 : 1;
+                opacityAverage += (1 - p.values.transparencyAverage.ref.value) * alpha * xray * fuzzy * text;
                 count += 1;
             }
             return count > 0 ? opacityAverage / count : 0;
         }
 
+        function calculateHasOpaque() {
+            if (primitives.length === 0) return false;
+            for (let i = 0, il = primitives.length; i < il; ++i) {
+                const p = primitives[i];
+                if (!p.state.visible) continue;
+
+                if (p.state.opaque) return true;
+                if (p.state.alphaFactor === 1 && p.values.alpha.ref.value === 1 && p.values.transparencyAverage.ref.value !== 1) return true;
+                if (p.values.dTransparentBackfaces?.ref.value === 'opaque') return true;
+            }
+            return false;
+        }
+
         return {
             view, position, direction, up,
 
@@ -245,6 +270,7 @@ namespace Scene {
                 }
                 markerAverage = calculateMarkerAverage();
                 opacityAverage = calculateOpacityAverage();
+                hasOpaque = calculateHasOpaque();
             },
             add: (o: GraphicsRenderObject) => commitQueue.add(o),
             remove: (o: GraphicsRenderObject) => commitQueue.remove(o),
@@ -281,7 +307,6 @@ namespace Scene {
                 if (boundingSphereVisibleDirty) {
                     calculateBoundingSphere(renderables, boundingSphereVisible, true);
                     boundingSphereVisibleDirty = false;
-                    visibleHash = computeVisibleHash();
                 }
                 return boundingSphereVisible;
             },
@@ -291,6 +316,9 @@ namespace Scene {
             get opacityAverage() {
                 return opacityAverage;
             },
+            get hasOpaque() {
+                return hasOpaque;
+            },
         };
     }
 }

+ 2 - 0
src/mol-gl/shader-code.ts

@@ -292,7 +292,9 @@ const glsl300VertPrefixCommon = `
 const glsl300FragPrefixCommon = `
 #define varying in
 #define texture2D texture
+#define textureCube texture
 #define texture2DLodEXT textureLod
+#define textureCubeLodEXT textureLod
 
 #define gl_FragColor out_FragData0
 #define gl_FragDepthEXT gl_FragDepth

+ 85 - 0
src/mol-gl/shader/background.frag.ts

@@ -0,0 +1,85 @@
+export const background_frag = `
+precision mediump float;
+precision mediump samplerCube;
+precision mediump sampler2D;
+
+#if defined(dVariant_skybox)
+    uniform samplerCube tSkybox;
+    uniform mat4 uViewDirectionProjectionInverse;
+    uniform float uOpacity;
+    uniform float uSaturation;
+    uniform float uLightness;
+#elif defined(dVariant_image)
+    uniform sampler2D tImage;
+    uniform vec2 uImageScale;
+    uniform vec2 uImageOffset;
+    uniform float uOpacity;
+    uniform float uSaturation;
+    uniform float uLightness;
+#elif defined(dVariant_horizontalGradient) || defined(dVariant_radialGradient)
+    uniform vec3 uGradientColorA;
+    uniform vec3 uGradientColorB;
+    uniform float uGradientRatio;
+#endif
+
+uniform vec2 uTexSize;
+uniform vec4 uViewport;
+uniform bool uViewportAdjusted;
+varying vec4 vPosition;
+
+// TODO: add as general pp option to remove banding?
+// Iestyn's RGB dither from http://alex.vlachos.com/graphics/Alex_Vlachos_Advanced_VR_Rendering_GDC2015.pdf
+vec3 ScreenSpaceDither(vec2 vScreenPos) {
+    vec3 vDither = vec3(dot(vec2(171.0, 231.0), vScreenPos.xy));
+    vDither.rgb = fract(vDither.rgb / vec3(103.0, 71.0, 97.0));
+    return vDither.rgb / 255.0;
+}
+
+vec3 saturateColor(vec3 c, float amount) {
+    // https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
+    const vec3 W = vec3(0.2125, 0.7154, 0.0721);
+    vec3 intensity = vec3(dot(c, W));
+    return mix(intensity, c, 1.0 + amount);
+}
+
+vec3 lightenColor(vec3 c, float amount) {
+    return c + amount;
+}
+
+void main() {
+    #if defined(dVariant_skybox)
+        vec4 t = uViewDirectionProjectionInverse * vPosition;
+        gl_FragColor = textureCube(tSkybox, normalize(t.xyz / t.w));
+        gl_FragColor.a = uOpacity;
+        gl_FragColor.rgb = lightenColor(saturateColor(gl_FragColor.rgb, uSaturation), uLightness);
+    #elif defined(dVariant_image)
+        vec2 coords;
+        if (uViewportAdjusted) {
+            coords = ((gl_FragCoord.xy - uViewport.xy) * (uTexSize / uViewport.zw) / uImageScale) + uImageOffset;
+        } else {
+            coords = (gl_FragCoord.xy / uImageScale) + uImageOffset;
+        }
+        gl_FragColor = texture2D(tImage, vec2(coords.x, 1.0 - coords.y));
+        gl_FragColor.a = uOpacity;
+        gl_FragColor.rgb = lightenColor(saturateColor(gl_FragColor.rgb, uSaturation), uLightness);
+    #elif defined(dVariant_horizontalGradient)
+        float d;
+        if (uViewportAdjusted) {
+            d = ((gl_FragCoord.y - uViewport.y) * (uTexSize.y / uViewport.w) / uTexSize.y) + 1.0 - (uGradientRatio * 2.0);
+        } else {
+            d = (gl_FragCoord.y / uTexSize.y) + 1.0 - (uGradientRatio * 2.0);
+        }
+        gl_FragColor = vec4(mix(uGradientColorB, uGradientColorA, clamp(d, 0.0, 1.0)), 1.0);
+        gl_FragColor.rgb += ScreenSpaceDither(gl_FragCoord.xy);
+    #elif defined(dVariant_radialGradient)
+        float d;
+        if (uViewportAdjusted) {
+            d = distance(vec2(0.5), (gl_FragCoord.xy - uViewport.xy) * (uTexSize / uViewport.zw) / uTexSize) + uGradientRatio - 0.5;
+        } else {
+            d = distance(vec2(0.5), gl_FragCoord.xy / uTexSize) + uGradientRatio - 0.5;
+        }
+        gl_FragColor = vec4(mix(uGradientColorB, uGradientColorA, 1.0 - clamp(d, 0.0, 1.0)), 1.0);
+        gl_FragColor.rgb += ScreenSpaceDither(gl_FragCoord.xy);
+    #endif
+}
+`;

+ 12 - 0
src/mol-gl/shader/background.vert.ts

@@ -0,0 +1,12 @@
+export const background_vert = `
+precision mediump float;
+
+attribute vec2 aPosition;
+
+varying vec4 vPosition;
+
+void main() {
+    vPosition = vec4(aPosition, 1.0, 1.0);
+    gl_Position = vec4(aPosition, 1.0, 1.0);
+}
+`;

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

@@ -36,7 +36,6 @@ uniform float uBumpiness;
             varying vec4 vColor;
         #endif
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         #ifdef requiredDrawBuffers
             flat in vec4 vObject;
             flat in vec4 vInstance;

+ 0 - 1
src/mol-gl/shader/chunks/color-vert-params.glsl.ts

@@ -64,7 +64,6 @@ uniform float uBumpiness;
             varying vec4 vColor;
         #endif
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         #ifdef requiredDrawBuffers
             flat out vec4 vObject;
             flat out vec4 vInstance;

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

@@ -17,7 +17,6 @@ uniform int uMarkingType;
         #if __VERSION__ == 100 || defined(dClippingType_instance) || !defined(dVaryingGroup)
             varying float vClipping;
         #else
-            // avoid flat until EXT_provoking_vertex is supported
             flat in float vClipping;
         #endif
     #endif
@@ -36,7 +35,6 @@ uniform int uMarkingType;
     #if __VERSION__ == 100 || defined(dMarkerType_instance) || !defined(dVaryingGroup)
         varying float vMarker;
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         flat in float vMarker;
     #endif
 #endif

+ 3 - 3
src/mol-gl/shader/chunks/common-vert-params.glsl.ts

@@ -24,7 +24,6 @@ uniform int uPickType;
         #if __VERSION__ == 100 || defined(dClippingType_instance) || !defined(dVaryingGroup)
             varying float vClipping;
         #else
-            // avoid flat until EXT_provoking_vertex is supported
             flat out float vClipping;
         #endif
     #endif
@@ -37,7 +36,6 @@ uniform int uPickType;
     #if __VERSION__ == 100 || defined(dMarkerType_instance) || !defined(dVaryingGroup)
         varying float vMarker;
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         flat out float vMarker;
     #endif
 #endif
@@ -46,7 +44,9 @@ varying vec3 vModelPosition;
 varying vec3 vViewPosition;
 
 #if defined(noNonInstancedActiveAttribs)
-    #define VertexID gl_VertexID
+    // int() is needed for some Safari versions
+    // see https://bugs.webkit.org/show_bug.cgi?id=244152
+    #define VertexID int(gl_VertexID)
 #else
     attribute float aVertex;
     #define VertexID int(aVertex)

+ 5 - 5
src/mol-gl/shader/cylinders.frag.ts

@@ -109,14 +109,14 @@ void main() {
 
     vec3 vViewPosition = vModelPosition + intersection.x * rayDir;
     vViewPosition = (uView * vec4(vViewPosition, 1.0)).xyz;
-    gl_FragDepthEXT = calcDepth(vViewPosition);
+    float fragmentDepth = calcDepth(vViewPosition);
 
-    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
+    if (fragmentDepth < 0.0) discard;
+    if (fragmentDepth > 1.0) discard;
 
-    if (gl_FragDepthEXT < 0.0) discard;
-    if (gl_FragDepthEXT > 1.0) discard;
+    gl_FragDepthEXT = fragmentDepth;
 
-    float fragmentDepth = gl_FragDepthEXT;
+    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
     #include assign_material_color
 
     #if defined(dRenderVariant_pick)

+ 7 - 7
src/mol-gl/shader/spheres.frag.ts

@@ -70,17 +70,17 @@ void main(void){
     }
 
     vec3 vViewPosition = cameraPos;
-    gl_FragDepthEXT = calcDepth(vViewPosition);
-    if (!flag && gl_FragDepthEXT >= 0.0) {
-        gl_FragDepthEXT = 0.0 + (0.0000001 / vRadius);
+    float fragmentDepth = calcDepth(vViewPosition);
+    if (!flag && fragmentDepth >= 0.0) {
+        fragmentDepth = 0.0 + (0.0000001 / vRadius);
     }
 
-    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
+    if (fragmentDepth < 0.0) discard;
+    if (fragmentDepth > 1.0) discard;
 
-    if (gl_FragDepthEXT < 0.0) discard;
-    if (gl_FragDepthEXT > 1.0) discard;
+    gl_FragDepthEXT = fragmentDepth;
 
-    float fragmentDepth = gl_FragDepthEXT;
+    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
     #include assign_material_color
 
     #if defined(dRenderVariant_pick)

+ 6 - 5
src/mol-gl/webgl/context.ts

@@ -142,12 +142,12 @@ export function readPixels(gl: GLRenderingContext, x: number, y: number, width:
     if (isDebugMode) checkError(gl);
 }
 
-function getDrawingBufferPixelData(gl: GLRenderingContext) {
+function getDrawingBufferPixelData(gl: GLRenderingContext, state: WebGLState) {
     const w = gl.drawingBufferWidth;
     const h = gl.drawingBufferHeight;
     const buffer = new Uint8Array(w * h * 4);
     unbindFramebuffer(gl);
-    gl.viewport(0, 0, w, h);
+    state.viewport(0, 0, w, h);
     readPixels(gl, 0, 0, w, h, buffer);
     return PixelData.flipY(PixelData.create(buffer, w, h));
 }
@@ -164,6 +164,7 @@ function createStats() {
             renderbuffer: 0,
             shader: 0,
             texture: 0,
+            cubeTexture: 0,
             vertexArray: 0,
         },
 
@@ -345,15 +346,15 @@ export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScal
         readPixelsAsync,
         waitForGpuCommandsComplete: () => waitForGpuCommandsComplete(gl),
         waitForGpuCommandsCompleteSync: () => waitForGpuCommandsCompleteSync(gl),
-        getDrawingBufferPixelData: () => getDrawingBufferPixelData(gl),
+        getDrawingBufferPixelData: () => getDrawingBufferPixelData(gl, state),
         clear: (red: number, green: number, blue: number, alpha: number) => {
             unbindFramebuffer(gl);
             state.enable(gl.SCISSOR_TEST);
             state.depthMask(true);
             state.colorMask(true, true, true, true);
             state.clearColor(red, green, blue, alpha);
-            gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
-            gl.scissor(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+            state.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+            state.scissor(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
             gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
         },
 

+ 3 - 3
src/mol-gl/webgl/render-item.ts

@@ -150,8 +150,8 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
         vertexArrays[k] = vertexArrayObject ? resources.vertexArray(programs[k], attributeBuffers, elementsBuffer) : null;
     }
 
-    let drawCount = values.drawCount.ref.value;
-    let instanceCount = values.instanceCount.ref.value;
+    let drawCount: number = values.drawCount.ref.value;
+    let instanceCount: number = values.instanceCount.ref.value;
 
     stats.drawCount += drawCount;
     stats.instanceCount += instanceCount;
@@ -168,7 +168,7 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
         getProgram: (variant: T) => programs[variant],
 
         render: (variant: T, sharedTexturesCount: number) => {
-            if (drawCount === 0 || instanceCount === 0 || ctx.isContextLost) return;
+            if (drawCount === 0 || instanceCount === 0) return;
             const program = programs[variant];
             if (program.id === currentProgramId && state.currentRenderItemId === id) {
                 program.setUniforms(uniformValueEntries);

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

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -17,7 +17,7 @@ import { hashString, hashFnv32a } from '../../mol-data/util';
 import { DefineValues, ShaderCode } from '../shader-code';
 import { RenderableSchema } from '../renderable/schema';
 import { createRenderbuffer, Renderbuffer, RenderbufferAttachment, RenderbufferFormat } from './renderbuffer';
-import { Texture, TextureKind, TextureFormat, TextureType, TextureFilter, createTexture } from './texture';
+import { Texture, TextureKind, TextureFormat, TextureType, TextureFilter, createTexture, CubeFaces, createCubeTexture } from './texture';
 import { VertexArray, createVertexArray } from './vertex-array';
 
 function defineValueHash(v: boolean | number | string): number {
@@ -59,6 +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,
     vertexArray: (program: Program, attributeBuffers: AttributeBuffers, elementsBuffer?: ElementsBuffer) => VertexArray,
 
     getByteCounts: () => ByteCounts
@@ -76,6 +77,7 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
         renderbuffer: new Set<Resource>(),
         shader: new Set<Resource>(),
         texture: new Set<Resource>(),
+        cubeTexture: new Set<Resource>(),
         vertexArray: new Set<Resource>(),
     };
 
@@ -137,6 +139,9 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
         texture: (kind: TextureKind, format: TextureFormat, type: TextureType, filter: TextureFilter) => {
             return wrap('texture', createTexture(gl, extensions, kind, format, type, filter));
         },
+        cubeTexture: (faces: CubeFaces, mipmaps: boolean, onload?: () => void) => {
+            return wrap('cubeTexture', createCubeTexture(gl, faces, mipmaps, onload));
+        },
         vertexArray: (program: Program, attributeBuffers: AttributeBuffers, elementsBuffer?: ElementsBuffer) => {
             return wrap('vertexArray', createVertexArray(gl, extensions, program, attributeBuffers, elementsBuffer));
         },
@@ -146,6 +151,9 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
             sets.texture.forEach(r => {
                 texture += (r as Texture).getByteCount();
             });
+            sets.cubeTexture.forEach(r => {
+                texture += (r as Texture).getByteCount();
+            });
 
             let attribute = 0;
             sets.attribute.forEach(r => {

+ 29 - 0
src/mol-gl/webgl/state.ts

@@ -69,6 +69,9 @@ export type WebGLState = {
     clearVertexAttribsState: () => void
     disableUnusedVertexAttribs: () => void
 
+    viewport: (x: number, y: number, width: number, height: number) => void
+    scissor: (x: number, y: number, width: number, height: number) => void
+
     reset: () => void
 }
 
@@ -95,6 +98,9 @@ export function createState(gl: GLRenderingContext): WebGLState {
     let maxVertexAttribs = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
     const vertexAttribsState: number[] = [];
 
+    let currentViewport: [number, number, number, number] = gl.getParameter(gl.VIEWPORT);
+    let currentScissor: [number, number, number, number] = gl.getParameter(gl.SCISSOR_BOX);
+
     const clearVertexAttribsState = () => {
         for (let i = 0; i < maxVertexAttribs; ++i) {
             vertexAttribsState[i] = 0;
@@ -222,6 +228,26 @@ export function createState(gl: GLRenderingContext): WebGLState {
             }
         },
 
+        viewport: (x: number, y: number, width: number, height: number) => {
+            if (x !== currentViewport[0] || y !== currentViewport[1] || width !== currentViewport[2] || height !== currentViewport[3]) {
+                gl.viewport(x, y, width, height);
+                currentViewport[0] = x;
+                currentViewport[1] = y;
+                currentViewport[2] = width;
+                currentViewport[3] = height;
+            }
+        },
+
+        scissor: (x: number, y: number, width: number, height: number) => {
+            if (x !== currentScissor[0] || y !== currentScissor[1] || width !== currentScissor[2] || height !== currentScissor[3]) {
+                gl.scissor(x, y, width, height);
+                currentScissor[0] = x;
+                currentScissor[1] = y;
+                currentScissor[2] = width;
+                currentScissor[3] = height;
+            }
+        },
+
         reset: () => {
             enabledCapabilities = {};
 
@@ -247,6 +273,9 @@ export function createState(gl: GLRenderingContext): WebGLState {
             for (let i = 0; i < maxVertexAttribs; ++i) {
                 vertexAttribsState[i] = 0;
             }
+
+            currentViewport = gl.getParameter(gl.VIEWPORT);
+            currentScissor = gl.getParameter(gl.SCISSOR_BOX);
         }
     };
 }

+ 119 - 1
src/mol-gl/webgl/texture.ts

@@ -11,8 +11,9 @@ import { RenderableSchema } from '../renderable/schema';
 import { idFactory } from '../../mol-util/id-factory';
 import { Framebuffer } from './framebuffer';
 import { isWebGL2, GLRenderingContext } from './compat';
-import { ValueOf } from '../../mol-util/type-helpers';
+import { isPromiseLike, ValueOf } from '../../mol-util/type-helpers';
 import { WebGLExtensions } from './extensions';
+import { objectForEach } from '../../mol-util/object';
 
 const getNextTextureId = idFactory();
 
@@ -423,6 +424,123 @@ export function loadImageTexture(src: string, cell: ValueCell<Texture>, texture:
 
 //
 
+export type CubeSide = 'nx' | 'ny' | 'nz' | 'px' | 'py' | 'pz';
+
+export type CubeFaces = {
+    [k in CubeSide]: string | File | Promise<Blob>;
+}
+
+export function getCubeTarget(gl: GLRenderingContext, side: CubeSide): number {
+    switch (side) {
+        case 'nx': return gl.TEXTURE_CUBE_MAP_NEGATIVE_X;
+        case 'ny': return gl.TEXTURE_CUBE_MAP_NEGATIVE_Y;
+        case 'nz': return gl.TEXTURE_CUBE_MAP_NEGATIVE_Z;
+        case 'px': return gl.TEXTURE_CUBE_MAP_POSITIVE_X;
+        case 'py': return gl.TEXTURE_CUBE_MAP_POSITIVE_Y;
+        case 'pz': return gl.TEXTURE_CUBE_MAP_POSITIVE_Z;
+    }
+}
+
+export function createCubeTexture(gl: GLRenderingContext, faces: CubeFaces, mipmaps: boolean, onload?: (errored?: boolean) => void): Texture {
+    const target = gl.TEXTURE_CUBE_MAP;
+    const filter = gl.LINEAR;
+    const internalFormat = gl.RGBA;
+    const format = gl.RGBA;
+    const type = gl.UNSIGNED_BYTE;
+
+    let size = 0;
+
+    const texture = gl.createTexture();
+    gl.bindTexture(target, texture);
+
+    let loadedCount = 0;
+    objectForEach(faces, (source, side) => {
+        if (!source) return;
+
+        const level = 0;
+        const cubeTarget = getCubeTarget(gl, side as CubeSide);
+
+        const image = new Image();
+        if (source instanceof File) {
+            image.src = URL.createObjectURL(source);
+        } else if (isPromiseLike(source)) {
+            source.then(blob => {
+                image.src = URL.createObjectURL(blob);
+            });
+        } else {
+            image.src = source;
+        }
+        image.addEventListener('load', () => {
+            if (size === 0) size = image.width;
+
+            gl.texImage2D(cubeTarget, level, internalFormat, size, size, 0, format, type, null);
+            gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
+            gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
+            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
+            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+            gl.bindTexture(target, texture);
+            gl.texImage2D(cubeTarget, level, internalFormat, format, type, image);
+
+            loadedCount += 1;
+            if (loadedCount === 6) {
+                if (!destroyed) {
+                    if (mipmaps) {
+                        gl.generateMipmap(target);
+                        gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
+                    } else {
+                        gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, filter);
+                    }
+                    gl.texParameteri(target, gl.TEXTURE_MAG_FILTER, filter);
+                }
+                onload?.(destroyed);
+            }
+        });
+        image.addEventListener('error', () => {
+            onload?.(true);
+        });
+    });
+
+    let destroyed = false;
+
+    return {
+        id: getNextTextureId(),
+        target,
+        format,
+        internalFormat,
+        type,
+        filter,
+
+        getWidth: () => size,
+        getHeight: () => size,
+        getDepth: () => 0,
+        getByteCount: () => {
+            return getByteCount('rgba', 'ubyte', size, size, 0) * 6 * (mipmaps ? 2 : 1);
+        },
+
+        define: () => {},
+        load: () => {},
+        bind: (id: TextureId) => {
+            gl.activeTexture(gl.TEXTURE0 + id);
+            gl.bindTexture(target, texture);
+        },
+        unbind: (id: TextureId) => {
+            gl.activeTexture(gl.TEXTURE0 + id);
+            gl.bindTexture(target, null);
+        },
+        attachFramebuffer: () => {},
+        detachFramebuffer: () => {},
+
+        reset: () => {},
+        destroy: () => {
+            if (destroyed) return;
+            gl.deleteTexture(texture);
+            destroyed = true;
+        },
+    };
+}
+
+//
+
 export function createNullTexture(gl?: GLRenderingContext): Texture {
     const target = gl?.TEXTURE_2D ?? 3553;
     return {

+ 1 - 1
src/mol-io/reader/cif/schema/bird.ts

@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.359, IHM 1.17, MA 1.4.1.
+ * Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.360, IHM 1.17, MA 1.4.2.
  *
  * @author molstar/ciftools package
  */

+ 1 - 1
src/mol-io/reader/cif/schema/ccd.ts

@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.359, IHM 1.17, MA 1.4.1.
+ * Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.360, IHM 1.17, MA 1.4.2.
  *
  * @author molstar/ciftools package
  */

+ 1 - 1
src/mol-io/reader/cif/schema/mmcif.ts

@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.359, IHM 1.17, MA 1.4.1.
+ * Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.360, IHM 1.17, MA 1.4.2.
  *
  * @author molstar/ciftools package
  */

+ 6 - 6
src/mol-math/geometry/gaussian-density/gpu.ts

@@ -166,8 +166,8 @@ function calcGaussianDensityTexture2d(webgl: WebGLContext, position: PositionDat
         state.currentRenderItemId = -1;
         fbTex.attachFramebuffer(framebuffer, 0);
         if (clear) {
-            gl.viewport(0, 0, width, height);
-            gl.scissor(0, 0, width, height);
+            state.viewport(0, 0, width, height);
+            state.scissor(0, 0, width, height);
             gl.clear(gl.COLOR_BUFFER_BIT);
         }
         ValueCell.update(uCurrentY, 0);
@@ -184,8 +184,8 @@ function calcGaussianDensityTexture2d(webgl: WebGLContext, position: PositionDat
             // console.log({ i, currX, currY });
             ValueCell.update(uCurrentX, currX);
             ValueCell.update(uCurrentSlice, i);
-            gl.viewport(currX, currY, dx, dy);
-            gl.scissor(currX, currY, dx, dy);
+            state.viewport(currX, currY, dx, dy);
+            state.scissor(currX, currY, dx, dy);
             renderable.render();
             ++currCol;
             currX += dx;
@@ -232,8 +232,8 @@ function calcGaussianDensityTexture3d(webgl: WebGLContext, position: PositionDat
     const framebuffer = getFramebuffer(webgl);
     framebuffer.bind();
     setRenderingDefaults(webgl);
-    gl.viewport(0, 0, dx, dy);
-    gl.scissor(0, 0, dx, dy);
+    state.viewport(0, 0, dx, dy);
+    state.scissor(0, 0, dx, dy);
 
     if (!texture) texture = colorBufferHalfFloat && textureHalfFloat
         ? resources.texture('volume-float16', 'rgba', 'fp16', 'linear')

+ 11 - 3
src/mol-math/geometry/primitives/box3d.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-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>
@@ -124,11 +124,19 @@ namespace Box3D {
     }
 
     export function containsVec3(box: Box3D, v: Vec3) {
-        return (
+        return !(
             v[0] < box.min[0] || v[0] > box.max[0] ||
             v[1] < box.min[1] || v[1] > box.max[1] ||
             v[2] < box.min[2] || v[2] > box.max[2]
-        ) ? false : true;
+        );
+    }
+
+    export function overlaps(a: Box3D, b: Box3D) {
+        return !(
+            a.max[0] < b.min[0] || a.min[0] > b.max[0] ||
+            a.max[1] < b.min[1] || a.min[1] > b.max[1] ||
+            a.max[2] < b.min[2] || a.min[2] > b.max[2]
+        );
     }
 }
 

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

@@ -35,7 +35,7 @@ function Vec3() {
 
 namespace Vec3 {
     export function zero(): Vec3 {
-        const out = [0.1, 0.0, 0.0];
+        const out = [0.1, 0.0, 0.0]; // ensure backing array of type double
         out[0] = 0;
         return out as any;
     }

+ 4 - 1
src/mol-model-formats/structure/mol.ts

@@ -80,7 +80,10 @@ export async function getMolModels(mol: MolFile, format: ModelFormat<any> | unde
         const indexA = Column.ofIntArray(Column.mapToArray(bonds.atomIdxA, x => x - 1, Int32Array));
         const indexB = Column.ofIntArray(Column.mapToArray(bonds.atomIdxB, x => x - 1, Int32Array));
         const order = Column.asArrayColumn(bonds.order, Int32Array);
-        const pairBonds = IndexPairBonds.fromData({ pairs: { indexA, indexB, order }, count: atoms.count });
+        const pairBonds = IndexPairBonds.fromData(
+            { pairs: { indexA, indexB, order }, count: atoms.count },
+            { maxDistance: Infinity }
+        );
         IndexPairBonds.Provider.set(models.representative, pairBonds);
     }
 

+ 4 - 1
src/mol-model-formats/structure/mol2.ts

@@ -113,7 +113,10 @@ async function getModels(mol2: Mol2File, ctx: RuntimeContext) {
                         return BondType.Flag.Covalent;
                 }
             }, Int8Array));
-            const pairBonds = IndexPairBonds.fromData({ pairs: { key, indexA, indexB, order, flag }, count: atoms.count });
+            const pairBonds = IndexPairBonds.fromData(
+                { pairs: { key, indexA, indexB, order, flag }, count: atoms.count },
+                { maxDistance: crysin ? -1 : Infinity }
+            );
 
             const first = _models.representative;
             IndexPairBonds.Provider.set(first, pairBonds);

+ 1 - 1
src/mol-model-props/common/custom-element-property.ts

@@ -106,7 +106,7 @@ namespace CustomElementProperty {
             factory: Coloring,
             getParams: () => ({}),
             defaultValues: {},
-            isApplicable: (ctx: ThemeDataContext) => !!ctx.structure && !!modelProperty.get(ctx.structure.models[0]).value,
+            isApplicable: (ctx: ThemeDataContext) => !!ctx.structure,
             ensureCustomProperties: {
                 attach: (ctx: CustomProperty.Context, data: ThemeDataContext) => data.structure ? modelProperty.attach(ctx, data.structure.models[0], void 0, true) : Promise.resolve(),
                 detach: (data: ThemeDataContext) => data.structure && data.structure.models[0].customProperties.reference(modelProperty.descriptor, false)

+ 48 - 14
src/mol-model-props/computed/representations/interactions-inter-unit-cylinder.ts

@@ -22,6 +22,8 @@ import { LocationIterator } from '../../../mol-geo/util/location-iterator';
 import { InteractionFlag } from '../interactions/common';
 import { Unit } from '../../../mol-model/structure/structure';
 import { Sphere3D } from '../../../mol-math/geometry';
+import { assertUnreachable } from '../../../mol-util/type-helpers';
+import { InteractionsSharedParams } from './shared';
 
 function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: Structure, theme: Theme, props: PD.Values<InteractionsInterUnitParams>, mesh?: Mesh) {
     if (!structure.hasAtomic) return Mesh.createEmpty(mesh);
@@ -31,7 +33,7 @@ function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: S
     const { contacts, unitsFeatures } = interactions;
 
     const { edgeCount, edges } = contacts;
-    const { sizeFactor } = props;
+    const { sizeFactor, parentDisplay } = props;
 
     if (!edgeCount) return Mesh.createEmpty(mesh);
 
@@ -70,14 +72,48 @@ function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: S
 
             if (child) {
                 const b = edges[edgeIndex];
-                const childUnitA = child.unitMap.get(b.unitA);
-                if (!childUnitA) return true;
-
-                const unitA = structure.unitMap.get(b.unitA);
-                const { offsets, members } = unitsFeatures.get(b.unitA);
-                for (let i = offsets[b.indexA], il = offsets[b.indexA + 1]; i < il; ++i) {
-                    const eA = unitA.elements[members[i]];
-                    if (!SortedArray.has(childUnitA.elements, eA)) return true;
+
+                if (parentDisplay === 'stub') {
+                    const childUnitA = child.unitMap.get(b.unitA);
+                    if (!childUnitA) return true;
+
+                    const unitA = structure.unitMap.get(b.unitA);
+                    const { offsets, members } = unitsFeatures.get(b.unitA);
+                    for (let i = offsets[b.indexA], il = offsets[b.indexA + 1]; i < il; ++i) {
+                        const eA = unitA.elements[members[i]];
+                        if (!SortedArray.has(childUnitA.elements, eA)) return true;
+                    }
+                } else if (parentDisplay === 'full' || parentDisplay === 'between') {
+                    let flagA = false;
+                    let flagB = false;
+
+                    const childUnitA = child.unitMap.get(b.unitA);
+                    if (!childUnitA) {
+                        flagA = true;
+                    } else {
+                        const unitA = structure.unitMap.get(b.unitA);
+                        const { offsets, members } = unitsFeatures.get(b.unitA);
+                        for (let i = offsets[b.indexA], il = offsets[b.indexA + 1]; i < il; ++i) {
+                            const eA = unitA.elements[members[i]];
+                            if (!SortedArray.has(childUnitA.elements, eA)) flagA = true;
+                        }
+                    }
+
+                    const childUnitB = child.unitMap.get(b.unitB);
+                    if (!childUnitB) {
+                        flagB = true;
+                    } else {
+                        const unitB = structure.unitMap.get(b.unitB);
+                        const { offsets, members } = unitsFeatures.get(b.unitB);
+                        for (let i = offsets[b.indexB], il = offsets[b.indexB + 1]; i < il; ++i) {
+                            const eB = unitB.elements[members[i]];
+                            if (!SortedArray.has(childUnitB.elements, eB)) flagB = true;
+                        }
+                    }
+
+                    return parentDisplay === 'full' ? flagA && flagB : flagA === flagB;
+                } else {
+                    assertUnreachable(parentDisplay);
                 }
             }
 
@@ -101,10 +137,7 @@ function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: S
 export const InteractionsInterUnitParams = {
     ...ComplexMeshParams,
     ...LinkCylinderParams,
-    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
-    dashCount: PD.Numeric(6, { min: 2, max: 10, step: 2 }),
-    dashScale: PD.Numeric(0.4, { min: 0, max: 2, step: 0.1 }),
-    includeParent: PD.Boolean(false),
+    ...InteractionsSharedParams,
 };
 export type InteractionsInterUnitParams = typeof InteractionsInterUnitParams
 
@@ -121,7 +154,8 @@ export function InteractionsInterUnitVisual(materialId: number): ComplexVisual<I
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
-                newProps.radialSegments !== currentProps.radialSegments
+                newProps.radialSegments !== currentProps.radialSegments ||
+                newProps.parentDisplay !== currentProps.parentDisplay
             );
 
             const interactionsHash = InteractionsProvider.get(newStructure).version;

+ 31 - 10
src/mol-model-props/computed/representations/interactions-intra-unit-cylinder.ts

@@ -22,6 +22,8 @@ import { Interactions } from '../interactions/interactions';
 import { InteractionFlag } from '../interactions/common';
 import { Sphere3D } from '../../../mol-math/geometry';
 import { StructureGroup } from '../../../mol-repr/structure/visual/util/common';
+import { assertUnreachable } from '../../../mol-util/type-helpers';
+import { InteractionsSharedParams } from './shared';
 
 async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: PD.Values<InteractionsIntraUnitParams>, mesh?: Mesh) {
     if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
@@ -38,7 +40,7 @@ async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit:
 
     const { x, y, z, members, offsets } = features;
     const { edgeCount, a, b, edgeProps: { flag } } = contacts;
-    const { sizeFactor } = props;
+    const { sizeFactor, parentDisplay } = props;
 
     if (!edgeCount) return Mesh.createEmpty(mesh);
 
@@ -60,10 +62,31 @@ async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit:
             if (flag[edgeIndex] === InteractionFlag.Filtered) return true;
 
             if (childUnit) {
-                const f = a[edgeIndex];
-                for (let i = offsets[f], jl = offsets[f + 1]; i < jl; ++i) {
-                    const e = unit.elements[members[offsets[i]]];
-                    if (!SortedArray.has(childUnit.elements, e)) return true;
+                if (parentDisplay === 'stub') {
+                    const f = a[edgeIndex];
+                    for (let i = offsets[f], il = offsets[f + 1]; i < il; ++i) {
+                        const e = unit.elements[members[offsets[i]]];
+                        if (!SortedArray.has(childUnit.elements, e)) return true;
+                    }
+                } else if (parentDisplay === 'full' || parentDisplay === 'between') {
+                    let flagA = false;
+                    let flagB = false;
+
+                    const fA = a[edgeIndex];
+                    for (let i = offsets[fA], il = offsets[fA + 1]; i < il; ++i) {
+                        const eA = unit.elements[members[offsets[i]]];
+                        if (!SortedArray.has(childUnit.elements, eA)) flagA = true;
+                    }
+
+                    const fB = b[edgeIndex];
+                    for (let i = offsets[fB], il = offsets[fB + 1]; i < il; ++i) {
+                        const eB = unit.elements[members[offsets[i]]];
+                        if (!SortedArray.has(childUnit.elements, eB)) flagB = true;
+                    }
+
+                    return parentDisplay === 'full' ? flagA && flagB : flagA === flagB;
+                } else {
+                    assertUnreachable(parentDisplay);
                 }
             }
 
@@ -86,10 +109,7 @@ async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit:
 export const InteractionsIntraUnitParams = {
     ...UnitsMeshParams,
     ...LinkCylinderParams,
-    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
-    dashCount: PD.Numeric(6, { min: 2, max: 10, step: 2 }),
-    dashScale: PD.Numeric(0.4, { min: 0, max: 2, step: 0.1 }),
-    includeParent: PD.Boolean(false),
+    ...InteractionsSharedParams,
 };
 export type InteractionsIntraUnitParams = typeof InteractionsIntraUnitParams
 
@@ -106,7 +126,8 @@ export function InteractionsIntraUnitVisual(materialId: number): UnitsVisual<Int
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
-                newProps.radialSegments !== currentProps.radialSegments
+                newProps.radialSegments !== currentProps.radialSegments ||
+                newProps.parentDisplay !== currentProps.parentDisplay
             );
 
             const interactionsHash = InteractionsProvider.get(newStructureGroup.structure).version;

+ 16 - 0
src/mol-model-props/computed/representations/shared.ts

@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+
+export const InteractionsSharedParams = {
+    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
+    dashCount: PD.Numeric(6, { min: 2, max: 10, step: 2 }),
+    dashScale: PD.Numeric(0.4, { min: 0, max: 2, step: 0.1 }),
+    includeParent: PD.Boolean(false),
+    parentDisplay: PD.Select('stub', PD.arrayToOptions(['stub', 'full', 'between'] as const), { description: 'Only has an effect when "includeParent" is enabled. "Stub" shows just the child side of interactions to the parent. "Full" shows both sides of interactions to the parent. "Between" shows only interactions to the parent.' }),
+};
+export type InteractionsSharedParams = typeof InteractionsSharedParams

+ 0 - 10
src/mol-model/structure/structure/carbohydrates/constants.ts

@@ -386,16 +386,6 @@ const DefaultSaccharideCompIdMap = (function () {
                 map.set(charmm[j], saccharide);
             }
         }
-
-        const glycam = GlycamSaccharideNames[saccharide.abbr];
-        if (glycam) {
-            for (let j = 0, jl = glycam.length; j < jl; ++j) {
-                // On collision, use PDB name as default.
-                if (!map.has(glycam[j])) {
-                    map.set(glycam[j], saccharide);
-                }
-            }
-        }
     }
     SaccharideNames.forEach(name => {
         if (!map.has(name)) map.set(name, UnknownSaccharideComponent);

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

@@ -23,7 +23,7 @@ import { Carbohydrates } from './carbohydrates/data';
 import { computeCarbohydrates } from './carbohydrates/compute';
 import { Vec3, Mat4 } from '../../../mol-math/linear-algebra';
 import { idFactory } from '../../../mol-util/id-factory';
-import { GridLookup3D } from '../../../mol-math/geometry';
+import { Box3D, GridLookup3D } from '../../../mol-math/geometry';
 import { UUID } from '../../../mol-util';
 import { CustomProperties } from '../../custom-property';
 import { AtomicHierarchy } from '../model/properties/atomic';
@@ -43,6 +43,8 @@ type State = {
     lookup3d?: StructureLookup3D,
     interUnitBonds?: InterUnitBonds,
     dynamicBonds: boolean,
+    interBondsValidUnit?: (unit: Unit) => boolean,
+    interBondsValidUnitPair?: (structure: Structure, unitA: Unit, unitB: Unit) => boolean,
     unitSymmetryGroups?: ReadonlyArray<Unit.SymmetryGroup>,
     unitSymmetryGroupsIndexMap?: IntMap<number>,
     unitsSortedByVolume?: ReadonlyArray<Unit>;
@@ -241,6 +243,8 @@ class Structure {
             this.state.interUnitBonds = computeInterUnitBonds(this, {
                 ignoreWater: !this.dynamicBonds,
                 ignoreIon: !this.dynamicBonds,
+                validUnit: this.state.interBondsValidUnit,
+                validUnitPair: this.state.interBondsValidUnitPair,
             });
         }
         return this.state.interUnitBonds;
@@ -250,6 +254,14 @@ class Structure {
         return this.state.dynamicBonds;
     }
 
+    get interBondsValidUnit() {
+        return this.state.interBondsValidUnit;
+    }
+
+    get interBondsValidUnitPair() {
+        return this.state.interBondsValidUnitPair;
+    }
+
     get unitSymmetryGroups(): ReadonlyArray<Unit.SymmetryGroup> {
         if (this.state.unitSymmetryGroups) return this.state.unitSymmetryGroups;
         this.state.unitSymmetryGroups = StructureSymmetry.computeTransformGroups(this);
@@ -380,7 +392,12 @@ class Structure {
             parent: parent?.remapModel(m),
             label: this.label,
             interUnitBonds: dynamicBonds ? undefined : interUnitBonds,
-            dynamicBonds
+            dynamicBonds,
+            interBondsValidUnit: this.state.interBondsValidUnit,
+            interBondsValidUnitPair: this.state.interBondsValidUnitPair,
+            coordinateSystem: this.state.coordinateSystem,
+            masterModel: this.state.masterModel,
+            representativeModel: this.state.representativeModel,
         });
     }
 
@@ -428,7 +445,6 @@ class Structure {
 
 function cmpUnits(units: ArrayLike<Unit>, i: number, j: number) {
     return units[i].id - units[j].id;
-
 }
 
 function getModels(s: Structure) {
@@ -634,6 +650,8 @@ namespace Structure {
          * Also enables calculation of inter-unit bonds in water molecules.
          */
         dynamicBonds?: boolean,
+        interBondsValidUnit?: (unit: Unit) => boolean,
+        interBondsValidUnitPair?: (structure: Structure, unitA: Unit, unitB: Unit) => boolean,
         coordinateSystem?: SymmetryOperator
         label?: string
         /** Master model for structures of a protein model and multiple ligand models */
@@ -722,6 +740,12 @@ namespace Structure {
         if (props.parent) state.parent = props.parent.parent || props.parent;
         if (props.interUnitBonds) state.interUnitBonds = props.interUnitBonds;
 
+        if (props.interBondsValidUnit) state.interBondsValidUnit = props.interBondsValidUnit;
+        else if (props.parent) state.interBondsValidUnit = props.parent.interBondsValidUnit;
+
+        if (props.interBondsValidUnitPair) state.interBondsValidUnitPair = props.interBondsValidUnitPair;
+        else if (props.parent) state.interBondsValidUnitPair = props.parent.interBondsValidUnitPair;
+
         if (props.dynamicBonds) state.dynamicBonds = props.dynamicBonds;
         else if (props.parent) state.dynamicBonds = props.parent.dynamicBonds;
 
@@ -1180,7 +1204,7 @@ namespace Structure {
 
     /**
      * Iterate over all unit pairs of a structure and invokes callback for valid units
-     * and unit pairs if within a max distance.
+     * and unit pairs if their boundaries are within a max distance.
      */
     export function eachUnitPair(structure: Structure, callback: (unitA: Unit, unitB: Unit) => void, props: EachUnitPairProps) {
         const { maxRadius, validUnit, validUnitPair } = props;
@@ -1188,15 +1212,19 @@ namespace Structure {
 
         const lookup = structure.lookup3d;
         const imageCenter = Vec3();
+        const bbox = Box3D();
+        const rvec = Vec3.create(maxRadius, maxRadius, maxRadius);
 
         for (const unit of structure.units) {
             if (!validUnit(unit)) continue;
 
             const bs = unit.boundary.sphere;
+            Box3D.expand(bbox, unit.boundary.box, rvec);
             Vec3.transformMat4(imageCenter, bs.center, unit.conformation.operator.matrix);
             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 (!Box3D.overlaps(bbox, other.boundary.box)) continue;
                 if (!validUnit(other) || unit.id >= other.id || !validUnitPair(unit, other)) continue;
 
                 if (other.elements.length >= unit.elements.length) callback(unit, other);

+ 14 - 6
src/mol-model/structure/structure/unit/bonds/inter-compute.ts

@@ -21,12 +21,18 @@ import { StructConn } from '../../../../../mol-model-formats/structure/property/
 import { equalEps } from '../../../../../mol-math/linear-algebra/3d/common';
 import { Model } from '../../../model';
 
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3distance = Vec3.distance;
+const v3set = Vec3.set;
+const v3squaredDistance = Vec3.squaredDistance;
+const v3transformMat4 = Vec3.transformMat4;
+
 const tmpDistVecA = Vec3();
 const tmpDistVecB = Vec3();
 function getDistance(unitA: Unit.Atomic, indexA: ElementIndex, unitB: Unit.Atomic, indexB: ElementIndex) {
     unitA.conformation.position(indexA, tmpDistVecA);
     unitB.conformation.position(indexB, tmpDistVecB);
-    return Vec3.distance(tmpDistVecA, tmpDistVecB);
+    return v3distance(tmpDistVecA, tmpDistVecB);
 }
 
 const _imageTransform = Mat4();
@@ -68,22 +74,22 @@ function findPairBonds(unitA: Unit.Atomic, unitB: Unit.Atomic, props: BondComput
 
     for (let _aI = 0 as StructureElement.UnitIndex; _aI < atomCount; _aI++) {
         const aI = atomsA[_aI];
-        Vec3.set(_imageA, xA[aI], yA[aI], zA[aI]);
-        if (isNotIdentity) Vec3.transformMat4(_imageA, _imageA, imageTransform);
-        if (Vec3.squaredDistance(_imageA, bCenter) > testDistanceSq) continue;
+        v3set(_imageA, xA[aI], yA[aI], zA[aI]);
+        if (isNotIdentity) v3transformMat4(_imageA, _imageA, imageTransform);
+        if (v3squaredDistance(_imageA, bCenter) > testDistanceSq) continue;
 
         if (!props.forceCompute && indexPairs) {
             const { maxDistance } = indexPairs;
             const { offset, b, edgeProps: { order, distance, flag } } = indexPairs.bonds;
 
             const srcA = sourceIndex.value(aI);
+            const aeI = getElementIdx(type_symbolA.value(aI));
             for (let i = offset[srcA], il = offset[srcA + 1]; i < il; ++i) {
                 const bI = invertedIndex![b[i]];
 
                 const _bI = SortedArray.indexOf(unitB.elements, bI) as StructureElement.UnitIndex;
                 if (_bI < 0) continue;
 
-                const aeI = getElementIdx(type_symbolA.value(aI));
                 const beI = getElementIdx(type_symbolA.value(bI));
 
                 const d = distance[i];
@@ -191,6 +197,7 @@ function findPairBonds(unitA: Unit.Atomic, unitB: Unit.Atomic, props: BondComput
 }
 
 export interface InterBondComputationProps extends BondComputationProps {
+    validUnit: (unit: Unit) => boolean
     validUnitPair: (structure: Structure, unitA: Unit, unitB: Unit) => boolean
     ignoreWater: boolean
     ignoreIon: boolean
@@ -215,7 +222,7 @@ function findBonds(structure: Structure, props: InterBondComputationProps) {
         findPairBonds(unitA as Unit.Atomic, unitB as Unit.Atomic, props, builder);
     }, {
         maxRadius: props.maxRadius,
-        validUnit: (unit: Unit) => Unit.isAtomic(unit),
+        validUnit: (unit: Unit) => props.validUnit(unit),
         validUnitPair: (unitA: Unit, unitB: Unit) => props.validUnitPair(structure, unitA, unitB)
     });
 
@@ -226,6 +233,7 @@ function computeInterUnitBonds(structure: Structure, props?: Partial<InterBondCo
     const p = { ...DefaultInterBondComputationProps, ...props };
     return findBonds(structure, {
         ...p,
+        validUnit: (props && props.validUnit) || (u => Unit.isAtomic(u)),
         validUnitPair: (props && props.validUnitPair) || ((s, a, b) => {
             const mtA = a.model.atomicHierarchy.derived.residue.moleculeType;
             const mtB = b.model.atomicHierarchy.derived.residue.moleculeType;

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

@@ -21,6 +21,9 @@ import { ElementIndex } from '../../../model/indexing';
 import { equalEps } from '../../../../../mol-math/linear-algebra/3d/common';
 import { Model } from '../../../model/model';
 
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3distance = Vec3.distance;
+
 function getGraph(atomA: StructureElement.UnitIndex[], atomB: StructureElement.UnitIndex[], _order: number[], _flags: number[], atomCount: number, canRemap: boolean): IntraUnitBonds {
     const builder = new IntAdjacencyGraph.EdgeBuilder(atomCount, atomA, atomB);
     const flags = new Uint16Array(builder.slotCount);
@@ -39,7 +42,7 @@ const tmpDistVecB = Vec3();
 function getDistance(unit: Unit.Atomic, indexA: ElementIndex, indexB: ElementIndex) {
     unit.conformation.position(indexA, tmpDistVecA);
     unit.conformation.position(indexB, tmpDistVecB);
-    return Vec3.distance(tmpDistVecA, tmpDistVecB);
+    return v3distance(tmpDistVecA, tmpDistVecB);
 }
 
 const __structConnAdded = new Set<StructureElement.UnitIndex>();

+ 21 - 5
src/mol-model/structure/structure/util/superposition-sifts-mapping.ts

@@ -8,7 +8,8 @@
 import { Segmentation } from '../../../../mol-data/int';
 import { MinimizeRmsd } from '../../../../mol-math/linear-algebra/3d/minimize-rmsd';
 import { SIFTSMapping } from '../../../../mol-model-props/sequence/sifts-mapping';
-import { ElementIndex } from '../../model/indexing';
+import { ElementIndex, ResidueIndex } from '../../model/indexing';
+import { StructureElement } from '../element';
 import { Structure } from '../structure';
 import { Unit } from '../unit';
 
@@ -24,11 +25,16 @@ export interface AlignmentResult {
     failedPairs: [number, number][]
 }
 
-export function alignAndSuperposeWithSIFTSMapping(structures: Structure[], options?: { traceOnly?: boolean }): AlignmentResult {
+type IncludeResidueTest = (traceElementOrFirstAtom: StructureElement.Location<Unit.Atomic>, residueIndex: ResidueIndex, startIndex: ElementIndex, endIndex: ElementIndex) => boolean
+
+export function alignAndSuperposeWithSIFTSMapping(
+    structures: Structure[],
+    options?: { traceOnly?: boolean, includeResidueTest?: IncludeResidueTest }
+): AlignmentResult {
     const indexMap = new Map<string, IndexEntry>();
 
     for (let i = 0; i < structures.length; i++) {
-        buildIndex(structures[i], indexMap, i, options?.traceOnly ?? true);
+        buildIndex(structures[i], indexMap, i, options?.traceOnly ?? true, options?.includeResidueTest ?? _includeAllResidues);
     }
 
     const index = Array.from(indexMap.values());
@@ -137,11 +143,16 @@ interface IndexEntry {
     pivots: { [i: number]: [unit: Unit.Atomic, start: ElementIndex, end: ElementIndex] | undefined }
 }
 
-function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: number, traceOnly: boolean) {
+function _includeAllResidues() { return true; }
+
+function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: number, traceOnly: boolean, includeTest: IncludeResidueTest) {
+    const loc = StructureElement.Location.create<Unit.Atomic>(structure);
+
     for (const unit of structure.units) {
         if (unit.kind !== Unit.Kind.Atomic) continue;
 
         const { elements, model } = unit;
+        loc.unit = unit;
 
         const map = SIFTSMapping.Provider.get(model).value;
         if (!map) return;
@@ -161,9 +172,11 @@ function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: nu
 
                 if (!dbName[rI]) continue;
 
+                const traceElement = traceElementIndex[rI];
+
                 let start, end;
                 if (traceOnly) {
-                    start = traceElementIndex[rI];
+                    start = traceElement;
                     if (start === -1) continue;
                     end = start + 1 as ElementIndex;
                 } else {
@@ -171,6 +184,9 @@ function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: nu
                     end = elements[residueSegment.end - 1] + 1 as ElementIndex;
                 }
 
+                loc.element = (traceElement >= 0 ? traceElement : start) as ElementIndex;
+                if (!includeTest(loc, rI, start, end)) continue;
+
                 const key = `${dbName[rI]}-${accession[rI]}-${num[rI]}`;
 
                 if (!index.has(key)) {

+ 13 - 7
src/mol-plugin-state/actions/file.ts

@@ -83,7 +83,7 @@ export const DownloadFile = StateAction.build({
     display: { name: 'Download File', description: 'Load one or more file from an URL' },
     from: PluginStateObject.Root,
     params: (a, ctx: PluginContext) => {
-        const options = [...ctx.dataFormats.options, ['zip', 'Zip'] as const];
+        const options = [...ctx.dataFormats.options, ['zip', 'Zip'] as const, ['gzip', 'Gzip'] as const];
         return {
             url: PD.Url(''),
             format: PD.Select(options[0][0], options),
@@ -96,17 +96,23 @@ export const DownloadFile = StateAction.build({
 
     await state.transaction(async () => {
         try {
-            if (params.format === 'zip') {
+            if (params.format === 'zip' || params.format === 'gzip') {
                 // TODO: add ReadZipFile transformer so this can be saved as a simple state snaphot,
                 //       would need support for extracting individual files from zip
                 const data = await plugin.builders.data.download({ url: params.url, isBinary: true });
-                const zippedFiles = await unzip(taskCtx, (data.obj?.data as Uint8Array).buffer);
-                for (const [fn, filedata] of Object.entries(zippedFiles)) {
-                    if (!(filedata instanceof Uint8Array) || filedata.length === 0) continue;
+                if (params.format === 'zip') {
+                    const zippedFiles = await unzip(taskCtx, (data.obj?.data as Uint8Array).buffer);
+                    for (const [fn, filedata] of Object.entries(zippedFiles)) {
+                        if (!(filedata instanceof Uint8Array) || filedata.length === 0) continue;
 
-                    const asset = Asset.File(new File([filedata], fn));
+                        const asset = Asset.File(new File([filedata], fn));
 
-                    await processFile(asset, plugin, 'auto', params.visuals);
+                        await processFile(asset, plugin, 'auto', params.visuals);
+                    }
+                } 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);
                 }
             } else {
                 const provider = plugin.dataFormats.get(params.format);

+ 2 - 1
src/mol-plugin-state/actions/structure.ts

@@ -90,6 +90,7 @@ const DownloadStructure = StateAction.build({
                     url: PD.Url(''),
                     format: PD.Select<BuiltInTrajectoryFormat>('mmcif', PD.arrayToOptions(BuiltInTrajectoryFormats.map(f => f[0]), f => f)),
                     isBinary: PD.Boolean(false),
+                    label: PD.Optional(PD.Text('')),
                     options
                 }, { isFlat: true, label: 'URL' })
             })
@@ -104,7 +105,7 @@ const DownloadStructure = StateAction.build({
 
     switch (src.name) {
         case 'url':
-            downloadParams = [{ url: src.params.url, isBinary: src.params.isBinary }];
+            downloadParams = [{ url: src.params.url, isBinary: src.params.isBinary, label: src.params.label || undefined }];
             format = src.params.format;
             break;
         case 'pdb':

+ 25 - 21
src/mol-plugin-state/builder/structure/representation-preset.ts

@@ -41,8 +41,10 @@ export namespace StructureRepresentationPresetProvider {
         quality: PD.Optional(PD.Select<VisualQuality>('auto', VisualQualityOptions)),
         theme: PD.Optional(PD.Group({
             globalName: PD.Optional(PD.Text<ColorTheme.BuiltIn>('')),
+            globalColorParams: PD.Optional(PD.Value<any>({}, { isHidden: true })),
             carbonColor: PD.Optional(PD.Select('chain-id', PD.arrayToOptions(['chain-id', 'operator-name', 'element-symbol'] as const))),
             symmetryColor: PD.Optional(PD.Text<ColorTheme.BuiltIn>('')),
+            symmetryColorParams: PD.Optional(PD.Value<any>({}, { isHidden: true })),
             focus: PD.Optional(PD.Group({
                 name: PD.Optional(PD.Text<ColorTheme.BuiltIn>('')),
                 params: PD.Optional(PD.Value<ColorTheme.BuiltInParams<ColorTheme.BuiltIn>>({} as any))
@@ -76,13 +78,15 @@ export namespace StructureRepresentationPresetProvider {
         if (params.ignoreLight !== void 0) typeParams.ignoreLight = !!params.ignoreLight;
         const color: ColorTheme.BuiltIn | undefined = params.theme?.globalName ? params.theme?.globalName : void 0;
         const ballAndStickColor: ColorTheme.BuiltInParams<'element-symbol'> = params.theme?.carbonColor !== undefined
-            ? { carbonColor: getCarbonColorParams(params.theme?.carbonColor) }
-            : { };
+            ? { carbonColor: getCarbonColorParams(params.theme?.carbonColor), ...params.theme?.globalColorParams }
+            : { ...params.theme?.globalColorParams };
         const symmetryColor: ColorTheme.BuiltIn | undefined = structure && params.theme?.symmetryColor
             ? isSymmetry(structure) ? params.theme?.symmetryColor : color
             : color;
+        const symmetryColorParams = params.theme?.symmetryColorParams ? { ...params.theme?.globalColorParams, ...params.theme?.symmetryColorParams } : { ...params.theme?.globalColorParams };
+        const globalColorParams = params.theme?.globalColorParams ? { ...params.theme?.globalColorParams } : undefined;
 
-        return { update, builder, color, symmetryColor, typeParams, ballAndStickColor };
+        return { update, builder, color, symmetryColor, symmetryColorParams, globalColorParams, typeParams, ballAndStickColor };
     }
 
     export function updateFocusRepr<T extends ColorTheme.BuiltIn>(plugin: PluginContext, structure: Structure, themeName: T | undefined, themeParams: ColorTheme.BuiltInParams<T> | undefined) {
@@ -177,18 +181,18 @@ const polymerAndLigand = StructureRepresentationPresetProvider({
         const waterType = (components.water?.obj?.data?.elementCount || 0) > 50_000 ? 'line' : 'ball-and-stick';
         const lipidType = (components.lipid?.obj?.data?.elementCount || 0) > 20_000 ? 'line' : 'ball-and-stick';
 
-        const { update, builder, typeParams, color, symmetryColor, ballAndStickColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, color, symmetryColor, symmetryColorParams, globalColorParams, ballAndStickColor } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor }, { tag: 'polymer' }),
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'polymer' }),
             ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams, color, colorParams: ballAndStickColor }, { tag: 'ligand' }),
             nonStandard: builder.buildRepresentation(update, components.nonStandard, { type: 'ball-and-stick', typeParams, color, colorParams: ballAndStickColor }, { tag: 'non-standard' }),
             branchedBallAndStick: builder.buildRepresentation(update, components.branched, { type: 'ball-and-stick', typeParams: { ...typeParams, alpha: 0.3 }, color, colorParams: ballAndStickColor }, { tag: 'branched-ball-and-stick' }),
-            branchedSnfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams, color }, { tag: 'branched-snfg-3d' }),
-            water: builder.buildRepresentation(update, components.water, { type: waterType, typeParams: { ...typeParams, alpha: 0.6, visuals: waterType === 'line' ? ['intra-bond', 'element-point'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'water' }),
-            ion: builder.buildRepresentation(update, components.ion, { type: 'ball-and-stick', typeParams, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ion' }),
-            lipid: builder.buildRepresentation(update, components.lipid, { type: lipidType, typeParams: { ...typeParams, alpha: 0.6, visuals: lipidType === 'line' ? ['intra-bond'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'lipid' }),
-            coarse: builder.buildRepresentation(update, components.coarse, { type: 'spacefill', typeParams, color: color || 'chain-id' }, { tag: 'coarse' })
+            branchedSnfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams, color, colorParams: globalColorParams }, { tag: 'branched-snfg-3d' }),
+            water: builder.buildRepresentation(update, components.water, { type: waterType, typeParams: { ...typeParams, alpha: 0.6, visuals: waterType === 'line' ? ['intra-bond', 'element-point'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams } }, { tag: 'water' }),
+            ion: builder.buildRepresentation(update, components.ion, { type: 'ball-and-stick', typeParams, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams } }, { tag: 'ion' }),
+            lipid: builder.buildRepresentation(update, components.lipid, { type: lipidType, typeParams: { ...typeParams, alpha: 0.6, visuals: lipidType === 'line' ? ['intra-bond'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams } }, { tag: 'lipid' }),
+            coarse: builder.buildRepresentation(update, components.coarse, { type: 'spacefill', typeParams, color: color || 'chain-id', colorParams: globalColorParams }, { tag: 'coarse' })
         };
 
         await update.commit({ revertOnError: false });
@@ -223,11 +227,11 @@ const proteinAndNucleic = StructureRepresentationPresetProvider({
             smoothness: structure.isCoarseGrained ? 1.0 : 1.5,
         };
 
-        const { update, builder, typeParams, symmetryColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, symmetryColor, symmetryColorParams } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            protein: builder.buildRepresentation(update, components.protein, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor }, { tag: 'protein' }),
-            nucleic: builder.buildRepresentation(update, components.nucleic, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor }, { tag: 'nucleic' })
+            protein: builder.buildRepresentation(update, components.protein, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'protein' }),
+            nucleic: builder.buildRepresentation(update, components.nucleic, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'nucleic' })
         };
 
         await update.commit({ revertOnError: true });
@@ -275,11 +279,11 @@ const coarseSurface = StructureRepresentationPresetProvider({
             });
         }
 
-        const { update, builder, typeParams, symmetryColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, symmetryColor, symmetryColorParams } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor }, { tag: 'polymer' }),
-            lipid: builder.buildRepresentation(update, components.lipid, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor }, { tag: 'lipid' })
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'polymer' }),
+            lipid: builder.buildRepresentation(update, components.lipid, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'lipid' })
         };
 
         await update.commit({ revertOnError: true });
@@ -309,10 +313,10 @@ const polymerCartoon = StructureRepresentationPresetProvider({
             sizeFactor: structure.isCoarseGrained ? 0.8 : 0.2
         };
 
-        const { update, builder, typeParams, symmetryColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, symmetryColor, symmetryColorParams } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor }, { tag: 'polymer' })
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'polymer' })
         };
 
         await update.commit({ revertOnError: true });
@@ -367,9 +371,9 @@ const atomicDetail = StructureRepresentationPresetProvider({
             });
         }
 
-        const { update, builder, typeParams, color, ballAndStickColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, color, ballAndStickColor, globalColorParams } = reprBuilder(plugin, params, structure);
         const colorParams = lowResidueElementRatio && !bondsGiven
-            ? { carbonColor: { name: 'element-symbol', params: {} } }
+            ? { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams }
             : ballAndStickColor;
 
         const representations = {
@@ -377,7 +381,7 @@ const atomicDetail = StructureRepresentationPresetProvider({
         };
         if (showCarbohydrateSymbol) {
             Object.assign(representations, {
-                snfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams: { ...typeParams, alpha: 0.4, visuals: ['carbohydrate-symbol'] }, color }, { tag: 'snfg-3d' }),
+                snfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams: { ...typeParams, alpha: 0.4, visuals: ['carbohydrate-symbol'] }, color, colorParams: globalColorParams }, { tag: 'snfg-3d' }),
             });
         }
 

+ 28 - 7
src/mol-plugin-ui/custom/volume.tsx

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 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 { PluginUIComponent } from '../base';
@@ -199,6 +200,9 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
             const viewParams = { ...oldView };
             if (value.name === 'selection-box') {
                 viewParams.radius = value.params.radius;
+            } else if (value.name === 'camera-target') {
+                viewParams.radius = value.params.radius;
+                viewParams.dynamicDetailLevel = value.params.dynamicDetailLevel;
             } else if (value.name === 'box') {
                 viewParams.bottomLeft = value.params.bottomLeft;
                 viewParams.topRight = value.params.topRight;
@@ -240,13 +244,23 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
         const pivot = isEM ? 'em' : '2fo-fc';
 
         const params = this.props.params as VolumeStreaming.Params;
-        const entry = ((this.props.info.params as VolumeStreaming.ParamDefinition)
-            .entry.map(params.entry.name) as PD.Group<VolumeStreaming.EntryParamDefinition>);
+        const entry = (this.props.info.params as VolumeStreaming.ParamDefinition)
+            .entry.map(params.entry.name) as PD.Group<VolumeStreaming.EntryParamDefinition>;
         const detailLevel = entry.params.detailLevel;
-        const isRelative = ((params.entry.params.channels as any)[pivot].isoValue as Volume.IsoValue).kind === 'relative';
+        const dynamicDetailLevel = {
+            ...detailLevel,
+            label: 'Dynamic Detail',
+            defaultValue: (entry.params.view as any).map('camera-target').params.dynamicDetailLevel.defaultValue,
+        };
+        const selectionDetailLevel = {
+            ...detailLevel,
+            label: 'Selection Detail',
+            defaultValue: (entry.params.view as any).map('auto').params.selectionDetailLevel.defaultValue,
+        };
 
         const sampling = b.info.header.sampling[0];
 
+        const isRelative = ((params.entry.params.channels as any)[pivot].isoValue as Volume.IsoValue).kind === 'relative';
         const isRelativeParam = PD.Boolean(isRelative, { description: 'Use normalized or absolute isocontour scale.', label: 'Normalized' });
 
         const isUnbounded = !!(params.entry.params.view.params as any).isUnbounded;
@@ -274,6 +288,13 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                     isRelative: isRelativeParam,
                     isUnbounded: isUnboundedParam,
                 }, { description: 'Box around focused element.' }),
+                'camera-target': PD.Group({
+                    radius: PD.Numeric(0.5, { min: 0, max: 1, step: 0.05 }, { description: 'Radius within which the volume is shown (relative to the field of view).' }),
+                    detailLevel: { ...detailLevel, isHidden: true },
+                    dynamicDetailLevel: dynamicDetailLevel,
+                    isRelative: isRelativeParam,
+                    isUnbounded: isUnboundedParam,
+                }, { description: 'Box around camera target.' }),
                 'cell': PD.Group({
                     detailLevel,
                     isRelative: isRelativeParam,
@@ -282,12 +303,11 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                 'auto': PD.Group({
                     radius: PD.Numeric(5, { min: 0, max: 50, step: 0.5 }, { description: 'Radius in \u212B within which the volume is shown.' }),
                     detailLevel,
-                    selectionDetailLevel: { ...detailLevel, label: 'Selection Detail' },
+                    selectionDetailLevel: selectionDetailLevel,
                     isRelative: isRelativeParam,
                     isUnbounded: isUnboundedParam,
                 }, { description: 'Box around focused element.' }),
-                // 'auto': PD.Group({  }), // TODO based on camera distance/active selection/whatever, show whole structure or slice.
-            }, { options: VolumeStreaming.ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Focus" shows the volume around the element/atom last interacted with. "Whole Structure" shows the volume for the whole structure.' })
+            }, { options: VolumeStreaming.ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Focus" shows the volume around the element/atom last interacted with. "Around Camera" shows the volume around the point the camera is targeting. "Whole Structure" shows the volume for the whole structure.' })
         };
         const options = {
             entry: params.entry.name,
@@ -299,6 +319,7 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                     bottomLeft: (params.entry.params.view.params as any).bottomLeft,
                     topRight: (params.entry.params.view.params as any).topRight,
                     selectionDetailLevel: (params.entry.params.view.params as any).selectionDetailLevel,
+                    dynamicDetailLevel: (params.entry.params.view.params as any).dynamicDetailLevel,
                     isRelative,
                     isUnbounded
                 }

+ 21 - 9
src/mol-plugin-ui/viewport/help.tsx

@@ -7,14 +7,15 @@
 import * as React from 'react';
 import { Binding } from '../../mol-util/binding';
 import { PluginUIComponent } from '../base';
-import { StateTransformer, StateSelection } from '../../mol-state';
+import { StateTransformer, StateSelection, State } from '../../mol-state';
 import { SelectLoci } from '../../mol-plugin/behavior/dynamic/representation';
 import { FocusLoci } from '../../mol-plugin/behavior/dynamic/representation';
 import { Icon, ArrowDropDownSvg, ArrowRightSvg, CameraSvg } from '../controls/icons';
 import { Button } from '../controls/common';
+import { memoizeLatest } from '../../mol-util/memoize';
 
 function getBindingsList(bindings: { [k: string]: Binding }) {
-    return Object.keys(bindings).map(k => [k, bindings[k]] as [string, Binding]);
+    return Object.keys(bindings).map(k => [k, bindings[k]] as [string, Binding]).filter(b => Binding.isBinding(b[1]));
 }
 
 export class BindingsHelp extends React.PureComponent<{ bindings: { [k: string]: Binding } }> {
@@ -77,19 +78,30 @@ export class ViewportHelpContent extends PluginUIComponent<{ selectOnly?: boolea
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
     }
 
-    render() {
-        const interactionBindings: { [k: string]: Binding } = {};
-        this.plugin.spec.behaviors.forEach(b => {
-            const { bindings } = b.defaultParams;
-            if (bindings) Object.assign(interactionBindings, bindings);
+    getInteractionBindings = memoizeLatest((cells: State.Cells) => {
+        let interactionBindings: { [k: string]: Binding } | undefined = void 0;
+
+        cells.forEach(c => {
+            const params = c.params?.values;
+            if (params?.bindings && Object.keys(params.bindings).length > 0) {
+                if (!interactionBindings) interactionBindings = { };
+                Object.assign(interactionBindings, params.bindings);
+            }
         });
+
+        return interactionBindings;
+    });
+
+    render() {
+        const interactionBindings = this.getInteractionBindings(this.plugin.state.behaviors.cells);
+
         return <>
             {(!this.props.selectOnly && this.plugin.canvas3d) && <HelpGroup key='trackball' header='Moving in 3D'>
                 <BindingsHelp bindings={this.plugin.canvas3d.props.trackball.bindings} />
             </HelpGroup>}
-            <HelpGroup key='interactions' header='Mouse Controls'>
+            {!!interactionBindings && <HelpGroup key='interactions' header='Mouse Controls'>
                 <BindingsHelp bindings={interactionBindings} />
-            </HelpGroup>
+            </HelpGroup>}
         </>;
     }
 }

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

@@ -8,8 +8,10 @@
 import { produce } from 'immer';
 import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
 import { PluginCommands } from '../../mol-plugin/commands';
+import { PluginConfig } from '../../mol-plugin/config';
 import { StateTransform } from '../../mol-state';
 import { Color } from '../../mol-util/color';
+import { deepClone } from '../../mol-util/object';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { ParamMapping } from '../../mol-util/param-mapping';
 import { Mutable } from '../../mol-util/type-helpers';
@@ -50,7 +52,8 @@ const SimpleSettingsParams = {
     camera: Canvas3DParams.camera,
     background: PD.Group({
         color: PD.Color(Color(0xFCFBF9), { label: 'Background', description: 'Custom background color' }),
-        transparent: PD.Boolean(false)
+        transparent: PD.Boolean(false),
+        style: Canvas3DParams.postprocessing.params.background,
     }, { pivot: 'color' }),
     lighting: PD.Group({
         occlusion: Canvas3DParams.postprocessing.params.occlusion,
@@ -75,6 +78,13 @@ const SimpleSettingsMapping = ParamMapping({
             if (controls.left !== 'none') options.push(['left', LayoutOptions.left]);
             params.layout.options = options;
         }
+        const bgStyles = ctx.config.get(PluginConfig.Background.Styles) || [];
+        if (bgStyles.length > 0) {
+            Object.assign(params.background.params.style, {
+                presets: deepClone(bgStyles),
+                isFlat: false, // so the presets menu is shown
+            });
+        }
         return params;
     },
     target(ctx: PluginUIContext) {
@@ -97,7 +107,8 @@ const SimpleSettingsMapping = ParamMapping({
             camera: canvas.camera,
             background: {
                 color: renderer.backgroundColor,
-                transparent: canvas.transparentBackground
+                transparent: canvas.transparentBackground,
+                style: canvas.postprocessing.background,
             },
             lighting: {
                 occlusion: canvas.postprocessing.occlusion,
@@ -117,6 +128,7 @@ const SimpleSettingsMapping = ParamMapping({
         canvas.renderer.backgroundColor = s.background.color;
         canvas.postprocessing.occlusion = s.lighting.occlusion;
         canvas.postprocessing.outline = s.lighting.outline;
+        canvas.postprocessing.background = s.background.style;
         canvas.cameraFog = s.lighting.fog;
         canvas.cameraClipping = {
             radius: s.clipping.radius,

+ 14 - 3
src/mol-plugin/behavior/behavior.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 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 { PluginStateTransform, PluginStateObject } from '../../mol-plugin-state/objects';
@@ -144,8 +145,18 @@ namespace PluginBehavior {
         protected subscribeCommand<T>(cmd: PluginCommand<T>, action: PluginCommand.Action<T>) {
             this.subs.push(cmd.subscribe(this.plugin, action));
         }
-        protected subscribeObservable<T>(o: Observable<T>, action: (v: T) => void) {
-            this.subs.push(o.subscribe(action));
+        protected subscribeObservable<T>(o: Observable<T>, action: (v: T) => void): PluginCommand.Subscription {
+            const sub = o.subscribe(action);
+            this.subs.push(sub);
+            return {
+                unsubscribe: () => {
+                    const idx = this.subs.indexOf(sub);
+                    if (idx >= 0) {
+                        this.subs.splice(idx, 1);
+                        sub.unsubscribe();
+                    }
+                }
+            };
         }
         dispose(): void {
             for (const s of this.subs) s.unsubscribe();

+ 163 - 84
src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-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>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
@@ -24,6 +25,10 @@ import { PluginContext } from '../../../context';
 import { EmptyLoci, Loci, isEmptyLoci } from '../../../../mol-model/loci';
 import { Asset } from '../../../../mol-util/assets';
 import { GlobalModelTransformInfo } from '../../../../mol-model/structure/model/properties/global-transform';
+import { distinctUntilChanged, filter, map, Observable, throttleTime } from 'rxjs';
+import { Camera } from '../../../../mol-canvas3d/camera';
+import { PluginCommand } from '../../../command';
+import { SingleAsyncQueue } from '../../../../mol-util/single-async-queue';
 
 export class VolumeStreaming extends PluginStateObject.CreateBehavior<VolumeStreaming.Behavior>({ name: 'Volume Streaming' }) { }
 
@@ -53,7 +58,7 @@ export namespace VolumeStreaming {
         valuesInfo: [{ mean: 0, min: -1, max: 1, sigma: 0.1 }, { mean: 0, min: -1, max: 1, sigma: 0.1 }]
     };
 
-    export function createParams(options: { data?: VolumeServerInfo.Data, defaultView?: ViewTypes, channelParams?: DefaultChannelParams } = { }) {
+    export function createParams(options: { data?: VolumeServerInfo.Data, defaultView?: ViewTypes, channelParams?: DefaultChannelParams } = {}) {
         const { data, defaultView, channelParams } = options;
         const map = new Map<string, VolumeServerInfo.EntryData>();
         if (data) data.entries.forEach(d => map.set(d.dataId, d));
@@ -68,7 +73,7 @@ export namespace VolumeStreaming {
     export type EntryParams = PD.Values<EntryParamDefinition>
 
     export function createEntryParams(options: { entryData?: VolumeServerInfo.EntryData, defaultView?: ViewTypes, structure?: Structure, channelParams?: DefaultChannelParams }) {
-        const { entryData, defaultView, structure, channelParams = { } } = options;
+        const { entryData, defaultView, structure, channelParams = {} } = options;
 
         // fake the info
         const info = entryData || { kind: 'em', header: { sampling: [fakeSampling], availablePrecisions: [{ precision: 0, maxVoxels: 0 }] }, emDefaultContourLevel: Volume.IsoValue.relative(0) };
@@ -86,19 +91,24 @@ export namespace VolumeStreaming {
                     bottomLeft: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
                     topRight: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
                 }, { description: 'Box around focused element.', isFlat: true }),
+                'camera-target': PD.Group({
+                    radius: PD.Numeric(0.5, { min: 0, max: 1, step: 0.05 }, { description: 'Radius within which the volume is shown (relative to the field of view).' }),
+                    // Minimal detail level for the inside of the zoomed region (real detail can be higher, depending on the region size)
+                    dynamicDetailLevel: createDetailParams(info.header.availablePrecisions, 0, { label: 'Dynamic Detail' }),
+                    bottomLeft: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
+                    topRight: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
+                }, { description: 'Box around camera target.', isFlat: true }),
                 'cell': PD.Group<{}>({}),
                 // Show selection-box if available and cell otherwise.
                 'auto': PD.Group({
                     radius: PD.Numeric(5, { min: 0, max: 50, step: 0.5 }, { description: 'Radius in \u212B within which the volume is shown.' }),
-                    selectionDetailLevel: PD.Select<number>(Math.min(6, info.header.availablePrecisions.length - 1),
-                        info.header.availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]), { label: 'Selection Detail', description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 0 (0.52M voxels) to 6 (25.17M voxels).' }),
+                    selectionDetailLevel: createDetailParams(info.header.availablePrecisions, 6, { label: 'Selection Detail' }),
                     isSelection: PD.Boolean(false, { isHidden: true }),
                     bottomLeft: PD.Vec3(box.min, {}, { isHidden: true }),
                     topRight: PD.Vec3(box.max, {}, { isHidden: true }),
                 }, { description: 'Box around focused element.', isFlat: true })
             }, { options: ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Interaction" shows the volume around the focused element/atom. "Whole Structure" shows the volume for the whole structure.' }),
-            detailLevel: PD.Select<number>(Math.min(3, info.header.availablePrecisions.length - 1),
-                info.header.availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]), { description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 0 (0.52M voxels) to 6 (25.17M voxels).' }),
+            detailLevel: createDetailParams(info.header.availablePrecisions, 3),
             channels: info.kind === 'em'
                 ? PD.Group({
                     'em': channelParam('EM', Color(0x638F8F), info.emDefaultContourLevel || Volume.IsoValue.relative(1), info.header.sampling[0].valuesInfo[0], channelParams['em'])
@@ -111,13 +121,40 @@ export namespace VolumeStreaming {
         };
     }
 
-    export const ViewTypeOptions = [['off', 'Off'], ['box', 'Bounded Box'], ['selection-box', 'Around Focus'], ['cell', 'Whole Structure'], ['auto', 'Auto']] as [ViewTypes, string][];
+    function createDetailParams(availablePrecisions: VolumeServerHeader.DetailLevel[], preferredPrecision: number, info?: PD.Info) {
+        return PD.Select<number>(Math.min(preferredPrecision, availablePrecisions.length - 1),
+            availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]),
+            {
+                description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 1 (0.52M voxels) to 7 (25.17M voxels).',
+                ...info
+            }
+        );
+    }
 
-    export type ViewTypes = 'off' | 'box' | 'selection-box' | 'cell' | 'auto'
+    export function copyParams(origParams: Params): Params {
+        return {
+            entry: {
+                name: origParams.entry.name,
+                params: {
+                    detailLevel: origParams.entry.params.detailLevel,
+                    channels: origParams.entry.params.channels,
+                    view: {
+                        name: origParams.entry.params.view.name,
+                        params: { ...origParams.entry.params.view.params } as any,
+                    }
+                }
+            }
+        };
+    }
+
+    export const ViewTypeOptions = [['off', 'Off'], ['box', 'Bounded Box'], ['selection-box', 'Around Focus'], ['camera-target', 'Around Camera'], ['cell', 'Whole Structure'], ['auto', 'Auto']] as [ViewTypes, string][];
+
+    export type ViewTypes = 'off' | 'box' | 'selection-box' | 'camera-target' | 'cell' | 'auto'
 
     export type ParamDefinition = ReturnType<typeof createParams>
     export type Params = PD.Values<ParamDefinition>
 
+
     type ChannelsInfo = { [name in ChannelType]?: { isoValue: Volume.IsoValue, color: Color, wireframe: boolean, opacity: number } }
     type ChannelsData = { [name in 'EM' | '2FO-FC' | 'FO-FC']?: Volume }
 
@@ -140,6 +177,14 @@ export namespace VolumeStreaming {
         private lastLoci: StructureElement.Loci | EmptyLoci = EmptyLoci;
         private ref: string = '';
         public infoMap: Map<string, VolumeServerInfo.EntryData>;
+        private updateQueue: SingleAsyncQueue;
+        private cameraTargetObservable = this.plugin.canvas3d!.didDraw!.pipe(
+            throttleTime(500, undefined, { 'leading': true, 'trailing': true }),
+            map(() => this.plugin.canvas3d?.camera.getSnapshot()),
+            distinctUntilChanged((a, b) => this.isCameraTargetSame(a, b)),
+            filter(a => a !== undefined),
+        ) as Observable<Camera.Snapshot>;
+        private cameraTargetSubscription?: PluginCommand.Subscription = undefined;
 
         channels: Channels = {};
 
@@ -163,6 +208,9 @@ export namespace VolumeStreaming {
             if (this.params.entry.params.view.name === 'auto' && this.params.entry.params.view.params.isSelection) {
                 detail = this.params.entry.params.view.params.selectionDetailLevel;
             }
+            if (this.params.entry.params.view.name === 'camera-target' && box) {
+                detail = this.decideDetail(box, this.params.entry.params.view.params.dynamicDetailLevel);
+            }
 
             url += `?detail=${detail}`;
 
@@ -201,58 +249,21 @@ export namespace VolumeStreaming {
             return ret;
         }
 
-        private updateSelectionBoxParams(box: Box3D) {
-            if (this.params.entry.params.view.name !== 'selection-box') return;
-
-            const state = this.plugin.state.data;
-            const newParams: Params = {
-                ...this.params,
-                entry: {
-                    name: this.params.entry.name,
-                    params: {
-                        ...this.params.entry.params,
-                        view: {
-                            name: 'selection-box' as const,
-                            params: {
-                                radius: this.params.entry.params.view.params.radius,
-                                bottomLeft: box.min,
-                                topRight: box.max
-                            }
-                        }
-                    }
-                }
-            };
-            const update = state.build().to(this.ref).update(newParams);
-
-            PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
-        }
-
-        private updateAutoParams(box: Box3D | undefined, isSelection: boolean) {
-            if (this.params.entry.params.view.name !== 'auto') return;
+        private async updateParams(box: Box3D | undefined, autoIsSelection: boolean = false) {
+            const newParams = copyParams(this.params);
+            const viewType = newParams.entry.params.view.name;
+            if (viewType !== 'off' && viewType !== 'cell') {
+                newParams.entry.params.view.params.bottomLeft = box?.min || Vec3.zero();
+                newParams.entry.params.view.params.topRight = box?.max || Vec3.zero();
+            }
+            if (viewType === 'auto') {
+                newParams.entry.params.view.params.isSelection = autoIsSelection;
+            }
 
             const state = this.plugin.state.data;
-            const newParams: Params = {
-                ...this.params,
-                entry: {
-                    name: this.params.entry.name,
-                    params: {
-                        ...this.params.entry.params,
-                        view: {
-                            name: 'auto' as const,
-                            params: {
-                                radius: this.params.entry.params.view.params.radius,
-                                selectionDetailLevel: this.params.entry.params.view.params.selectionDetailLevel,
-                                isSelection,
-                                bottomLeft: box?.min || Vec3.zero(),
-                                topRight: box?.max || Vec3.zero()
-                            }
-                        }
-                    }
-                }
-            };
             const update = state.build().to(this.ref).update(newParams);
 
-            PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
+            await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
         }
 
         private getStructureRoot() {
@@ -303,6 +314,18 @@ export namespace VolumeStreaming {
             }
         }
 
+        private isCameraTargetSame(a?: Camera.Snapshot, b?: Camera.Snapshot): boolean {
+            if (!a || !b) return false;
+            const targetSame = Vec3.equals(a.target, b.target);
+            const sqDistA = Vec3.squaredDistance(a.target, a.position);
+            const sqDistB = Vec3.squaredDistance(b.target, b.position);
+            const distanceSame = Math.abs(sqDistA - sqDistB) / sqDistA < 1e-3;
+            return targetSame && distanceSame;
+        }
+        private cameraTargetDistance(snapshot: Camera.Snapshot): number {
+            return Vec3.distance(snapshot.target, snapshot.position);
+        }
+
         private _invTransform: Mat4 = Mat4();
         private getBoxFromLoci(loci: StructureElement.Loci | EmptyLoci): Box3D {
             if (Loci.isEmpty(loci) || isEmptyLoci(loci)) {
@@ -328,39 +351,82 @@ export namespace VolumeStreaming {
         }
 
         private updateAuto(loci: StructureElement.Loci | EmptyLoci) {
-            // if (Loci.areEqual(this.lastLoci, loci)) {
-            //     this.lastLoci = EmptyLoci;
-            //     this.updateSelectionBoxParams(Box3D.empty());
-            //     return;
-            // }
-
-            this.lastLoci = loci;
-
-            if (isEmptyLoci(loci)) {
-                this.updateAutoParams(this.info.kind === 'x-ray' ? this.data.structure.boundary.box : void 0, false);
-                return;
-            }
-
-            const box = this.getBoxFromLoci(loci);
-            this.updateAutoParams(box, true);
+            this.updateQueue.enqueue(async () => {
+                this.lastLoci = loci;
+                if (isEmptyLoci(loci)) {
+                    await this.updateParams(this.info.kind === 'x-ray' ? this.data.structure.boundary.box : void 0, false);
+                } else {
+                    await this.updateParams(this.getBoxFromLoci(loci), true);
+                }
+            });
         }
 
         private updateSelectionBox(loci: StructureElement.Loci | EmptyLoci) {
-            if (Loci.areEqual(this.lastLoci, loci)) {
-                this.lastLoci = EmptyLoci;
-                this.updateSelectionBoxParams(Box3D());
-                return;
-            }
+            this.updateQueue.enqueue(async () => {
+                if (Loci.areEqual(this.lastLoci, loci)) {
+                    this.lastLoci = EmptyLoci;
+                } else {
+                    this.lastLoci = loci;
+                }
+                const box = this.getBoxFromLoci(this.lastLoci);
+                await this.updateParams(box);
+            });
+        }
 
-            this.lastLoci = loci;
+        private updateCameraTarget(snapshot: Camera.Snapshot) {
+            this.updateQueue.enqueue(async () => {
+                const origManualReset = this.plugin.canvas3d?.props.camera.manualReset;
+                try {
+                    if (!origManualReset) this.plugin.canvas3d?.setProps({ camera: { manualReset: true } });
+                    const box = this.boxFromCameraTarget(snapshot, true);
+                    await this.updateParams(box);
+                } finally {
+                    if (!origManualReset) this.plugin.canvas3d?.setProps({ camera: { manualReset: origManualReset } });
+                }
+            });
+        }
 
-            if (isEmptyLoci(loci)) {
-                this.updateSelectionBoxParams(Box3D());
-                return;
+        private boxFromCameraTarget(snapshot: Camera.Snapshot, boundByBoundarySize: boolean): Box3D {
+            const target = snapshot.target;
+            const distance = this.cameraTargetDistance(snapshot);
+            const top = Math.tan(0.5 * snapshot.fov) * distance;
+            let radius = top;
+            const viewport = this.plugin.canvas3d?.camera.viewport;
+            if (viewport && viewport.width > viewport.height) {
+                radius *= viewport.width / viewport.height;
+            }
+            const relativeRadius = this.params.entry.params.view.name === 'camera-target' ? this.params.entry.params.view.params.radius : 0.5;
+            radius *= relativeRadius;
+            let radiusX, radiusY, radiusZ;
+            if (boundByBoundarySize) {
+                const bBoxSize = Vec3.zero();
+                Box3D.size(bBoxSize, this.data.structure.boundary.box);
+                radiusX = Math.min(radius, 0.5 * bBoxSize[0]);
+                radiusY = Math.min(radius, 0.5 * bBoxSize[1]);
+                radiusZ = Math.min(radius, 0.5 * bBoxSize[2]);
+            } else {
+                radiusX = radiusY = radiusZ = radius;
             }
+            return Box3D.create(
+                Vec3.create(target[0] - radiusX, target[1] - radiusY, target[2] - radiusZ),
+                Vec3.create(target[0] + radiusX, target[1] + radiusY, target[2] + radiusZ)
+            );
+        }
 
-            const box = this.getBoxFromLoci(loci);
-            this.updateSelectionBoxParams(box);
+        private decideDetail(box: Box3D, baseDetail: number): number {
+            const cellVolume = this.info.kind === 'x-ray'
+                ? Box3D.volume(this.data.structure.boundary.box)
+                : this.info.header.spacegroup.size.reduce((a, b) => a * b, 1);
+            const boxVolume = Box3D.volume(box);
+            let ratio = boxVolume / cellVolume;
+            const maxDetail = this.info.header.availablePrecisions.length - 1;
+            let detail = baseDetail;
+            while (ratio <= 0.5 && detail < maxDetail) {
+                ratio *= 2;
+                detail += 1;
+            }
+            // console.log(`Decided dynamic detail: ${detail}, (base detail: ${baseDetail}, box/cell volume ratio: ${boxVolume / cellVolume})`);
+            return detail;
         }
 
         async update(params: Params) {
@@ -369,6 +435,11 @@ export namespace VolumeStreaming {
             this.params = params;
             let box: Box3D | undefined = void 0, emptyData = false;
 
+            if (params.entry.params.view.name !== 'camera-target' && this.cameraTargetSubscription) {
+                this.cameraTargetSubscription.unsubscribe();
+                this.cameraTargetSubscription = undefined;
+            }
+
             switch (params.entry.params.view.name) {
                 case 'off':
                     emptyData = true;
@@ -388,6 +459,12 @@ export namespace VolumeStreaming {
                     Box3D.expand(box, box, Vec3.create(r, r, r));
                     break;
                 }
+                case 'camera-target':
+                    if (!this.cameraTargetSubscription) {
+                        this.cameraTargetSubscription = this.subscribeObservable(this.cameraTargetObservable, (e) => this.updateCameraTarget(e));
+                    }
+                    box = this.boxFromCameraTarget(this.plugin.canvas3d!.camera.getSnapshot(), true);
+                    break;
                 case 'cell':
                     box = this.info.kind === 'x-ray'
                         ? this.data.structure.boundary.box
@@ -439,6 +516,7 @@ export namespace VolumeStreaming {
 
         getDescription() {
             if (this.params.entry.params.view.name === 'selection-box') return 'Selection';
+            if (this.params.entry.params.view.name === 'camera-target') return 'Camera';
             if (this.params.entry.params.view.name === 'box') return 'Static Box';
             if (this.params.entry.params.view.name === 'cell') return 'Cell';
             return '';
@@ -449,6 +527,7 @@ export namespace VolumeStreaming {
 
             this.infoMap = new Map<string, VolumeServerInfo.EntryData>();
             this.data.entries.forEach(info => this.infoMap.set(info.dataId, info));
+            this.updateQueue = new SingleAsyncQueue();
         }
     }
-}
+}

+ 3 - 1
src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-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>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginStateObject as SO, PluginStateTransform } from '../../../../mol-plugin-state/objects';
@@ -219,6 +220,7 @@ const CreateVolumeStreamingBehavior = PluginStateTransform.BuiltIn({
     canAutoUpdate: ({ oldParams, newParams }) => {
         return oldParams.entry.params.view === newParams.entry.params.view
             || newParams.entry.params.view.name === 'selection-box'
+            || newParams.entry.params.view.name === 'camera-target'
             || newParams.entry.params.view.name === 'off';
     },
     apply: ({ a, params }, plugin: PluginContext) => Task.create('Volume streaming', async _ => {

+ 6 - 2
src/mol-plugin/config.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-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>
@@ -12,6 +12,7 @@ import { EmdbDownloadProvider } from '../mol-plugin-state/actions/volume';
 import { StructureRepresentationPresetProvider } from '../mol-plugin-state/builder/structure/representation-preset';
 import { PluginFeatureDetection } from './features';
 import { SaccharideCompIdMapType } from '../mol-model/structure/structure/carbohydrates/constants';
+import { BackgroundProps } from '../mol-canvas3d/passes/background';
 
 export class PluginConfigItem<T = any> {
     toString() { return this.key; }
@@ -30,7 +31,7 @@ export const PluginConfig = {
         PixelScale: item('plugin-config.pixel-scale', 1),
         PickScale: item('plugin-config.pick-scale', 0.25),
         PickPadding: item('plugin-config.pick-padding', 3),
-        EnableWboit: item('plugin-config.enable-wboit', PluginFeatureDetection.wboit),
+        EnableWboit: item('plugin-config.enable-wboit', true),
         // as of Oct 1 2021, WebGL 2 doesn't work on iOS 15.
         // TODO: check back in a few weeks to see if it was fixed
         PreferWebGl1: item('plugin-config.prefer-webgl1', PluginFeatureDetection.preferWebGl1),
@@ -65,6 +66,9 @@ export const PluginConfig = {
         DefaultRepresentationPreset: item<string>('structure.default-representation-preset', 'auto'),
         DefaultRepresentationPresetParams: item<StructureRepresentationPresetProvider.CommonParams>('structure.default-representation-preset-params', { }),
         SaccharideCompIdMapType: item<SaccharideCompIdMapType>('structure.saccharide-comp-id-map-type', 'default'),
+    },
+    Background: {
+        Styles: item<[BackgroundProps, string][]>('background.styles', []),
     }
 };
 

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

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

+ 3 - 8
src/mol-plugin/features.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2021-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>
  */
 
 export const PluginFeatureDetection = {
@@ -13,7 +14,7 @@ export const PluginFeatureDetection = {
         const unpportedSafariVersions = [
             'Version/15.1 Safari',
             'Version/15.2 Safari',
-            'Version/15.3 Safari'
+            'Version/15.3 Safari',
         ];
         if (unpportedSafariVersions.some(v => navigator.userAgent.indexOf(v) > 0)) {
             return true;
@@ -28,10 +29,4 @@ export const PluginFeatureDetection = {
         const isTouchScreen = navigator.maxTouchPoints >= 4; // true for iOS 13 (and hopefully beyond)
         return !(window as any).MSStream && (isIOS || (isAppleDevice && isTouchScreen));
     },
-    get wboit() {
-        if (typeof navigator === 'undefined' || typeof window === 'undefined') return true;
-
-        // disable Wboit in Safari 15
-        return !/Version\/15.\d Safari/.test(navigator.userAgent);
-    }
 };

+ 3 - 1
src/mol-plugin/util/viewport-screenshot.ts

@@ -309,7 +309,9 @@ class ViewportScreenshotHelper extends PluginComponent {
         if (width <= 0 || height <= 0) return;
 
         await ctx.update('Rendering image...');
-        const imageData = this.imagePass.getImageData(width, height, viewport);
+        const pass = this.imagePass;
+        await pass.updateBackground();
+        const imageData = pass.getImageData(width, height, viewport);
 
         await ctx.update('Encoding image...');
         const canvas = this.canvas;

+ 4 - 0
src/mol-util/binding.ts

@@ -24,6 +24,10 @@ namespace Binding {
         return { triggers, action, description };
     }
 
+    export function isBinding(x: any): x is Binding {
+        return !!x && Array.isArray(x.triggers) && typeof x.action === 'string';
+    }
+
     export const Empty: Binding = { triggers: [], action: '', description: '' };
     export function isEmpty(binding: Binding) {
         return binding.triggers.length === 0 ||

+ 41 - 0
src/mol-util/single-async-queue.ts

@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+
+/** Job queue that allows at most one running and one pending job.
+ * A newly enqueued job will cancel any other pending jobs. */
+export class SingleAsyncQueue {
+    private isRunning: boolean;
+    private queue: { id: number, func: () => any }[];
+    private counter: number;
+    private log: boolean;
+    constructor(log: boolean = false) {
+        this.isRunning = false;
+        this.queue = [];
+        this.counter = 0;
+        this.log = log;
+    }
+    enqueue(job: () => any) {
+        if (this.log) console.log('SingleAsyncQueue enqueue', this.counter);
+        this.queue[0] = { id: this.counter, func: job };
+        this.counter++;
+        this.run(); // do not await
+    }
+    private async run() {
+        if (this.isRunning) return;
+        const job = this.queue.pop();
+        if (!job) return;
+        this.isRunning = true;
+        try {
+            if (this.log) console.log('SingleAsyncQueue run', job.id);
+            await job.func();
+            if (this.log) console.log('SingleAsyncQueue complete', job.id);
+        } finally {
+            this.isRunning = false;
+            this.run();
+        }
+    }
+}

+ 4 - 1
src/tests/browser/marching-cubes.ts

@@ -22,6 +22,7 @@ import { Representation } from '../../mol-repr/representation';
 import { computeMarchingCubesMesh } from '../../mol-geo/util/marching-cubes/algorithm';
 import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -31,7 +32,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas), PD.merge(Canvas3DParams, PD.getDefaultValues(Canvas3DParams), {
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager), PD.merge(Canvas3DParams, PD.getDefaultValues(Canvas3DParams), {
     renderer: { backgroundColor: ColorNames.white },
     camera: { mode: 'orthographic' }
 }));

+ 4 - 1
src/tests/browser/render-lines.ts

@@ -15,6 +15,7 @@ import { Color } from '../../mol-util/color';
 import { createRenderObject } from '../../mol-gl/render-object';
 import { Representation } from '../../mol-repr/representation';
 import { ParamDefinition } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -24,7 +25,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function linesRepr() {

+ 4 - 1
src/tests/browser/render-mesh.ts

@@ -17,6 +17,7 @@ import { createRenderObject } from '../../mol-gl/render-object';
 import { Representation } from '../../mol-repr/representation';
 import { Torus } from '../../mol-geo/primitive/torus';
 import { ParamDefinition } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -26,7 +27,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function meshRepr() {

+ 4 - 1
src/tests/browser/render-shape.ts

@@ -19,6 +19,7 @@ import { Sphere } from '../../mol-geo/primitive/sphere';
 import { ColorNames } from '../../mol-util/color/names';
 import { Shape } from '../../mol-model/shape';
 import { ShapeRepresentation } from '../../mol-repr/shape/representation';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -28,6 +29,8 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
+const assetManager = new AssetManager();
+
 const info = document.createElement('div');
 info.style.position = 'absolute';
 info.style.fontFamily = 'sans-serif';
@@ -38,7 +41,7 @@ info.style.color = 'white';
 parent.appendChild(info);
 
 let prevReprLoci = Representation.Loci.Empty;
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 canvas3d.input.move.subscribe(({ x, y }) => {
     const pickingId = canvas3d.identify(x, y)?.id;

+ 4 - 1
src/tests/browser/render-spheres.ts

@@ -13,6 +13,7 @@ import { Color } from '../../mol-util/color';
 import { createRenderObject } from '../../mol-gl/render-object';
 import { Representation } from '../../mol-repr/representation';
 import { ParamDefinition } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -22,7 +23,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function spheresRepr() {

+ 4 - 2
src/tests/browser/render-structure.ts

@@ -37,7 +37,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 const info = document.createElement('div');
@@ -123,7 +125,7 @@ function getMembraneOrientationRepr() {
 }
 
 async function init() {
-    const ctx = { runtime: SyncRuntimeContext, assetManager: new AssetManager() };
+    const ctx = { runtime: SyncRuntimeContext, assetManager };
 
     const cif = await downloadFromPdb('3pqr');
     const models = await getModels(cif);

+ 4 - 1
src/tests/browser/render-text.ts

@@ -15,6 +15,7 @@ import { SpheresBuilder } from '../../mol-geo/geometry/spheres/spheres-builder';
 import { createRenderObject } from '../../mol-gl/render-object';
 import { Spheres } from '../../mol-geo/geometry/spheres/spheres';
 import { resizeCanvas } from '../../mol-canvas3d/util';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -24,7 +25,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function textRepr() {

+ 6 - 2
webpack.config.common.js

@@ -30,7 +30,11 @@ const sharedConfig = {
                     { loader: 'css-loader', options: { sourceMap: false } },
                     { loader: 'sass-loader', options: { sourceMap: false } },
                 ]
-            }
+            },
+            {
+                test: /\.(jpg)$/i,
+                type: 'asset/resource',
+            },
         ]
     },
     plugins: [
@@ -76,7 +80,7 @@ function createEntry(src, outFolder, outFilename, isNode) {
 function createEntryPoint(name, dir, out, library) {
     return {
         entry: path.resolve(__dirname, `lib/${dir}/${name}.js`),
-        output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd' },
+        output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd', assetModuleFilename: 'images/[hash][ext][query]', 'publicPath': '' },
         ...sharedConfig
     };
 }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません