Compare commits

...

43 Commits

Author SHA1 Message Date
dsehnal
c04580377b 5.0.0-dev.13 2025-09-03 09:20:26 +02:00
David Sehnal
a492b38368 fix mutative use & assign NODE_ENV=production for prd builds (#1642)
* fix mutative use & assign NODE_ENV=production for prd builds

* fix type
2025-09-03 09:07:23 +02:00
midlik
518f21531e SequenceColor extension (#1611)
* MinimizeRmsd.Result include nAlignedElements

* SequenceColor extension

* SequenceColor extension - forceUpdate when custom prop changes

* SequenceColor extension - proper caching

* Update CHANGELOG

* SequenceColor extension - registry

* SequenceColor extension - refactor

* SequenceColor extension - minor changes

* SequenceColor extension - switch to experimentalSequenceColorTheme

* SequenceColor extension - ensureCustomProperties

* SequenceColor extension - clean

* SequenceColor extension - avoid repeated allocation for Location

* SequenceColor extension - memoizeLatest, but wrong

* SequenceColor extension - memoizeLatest fixed

* SequenceColor extension - remove unnecessary loci caching

* SequenceColor extension - clean up
2025-09-03 07:29:40 +02:00
David Sehnal
36fd40ee09 VolumeServer: Default to P1 spacegroup (#1640)
* CCP4 parser defaultToP1 option

* volume server: default to P1

* tweaks

* tweak
2025-09-02 17:47:58 +02:00
Alexander Rose
0e968ae59c Fix ColorScale for continuous case without offsets 2025-09-01 16:09:12 -07:00
dsehnal
005824eb24 5.0.0-dev.12 2025-08-31 10:08:11 +02:00
dsehnal
259e04a6ce move mvs validation to a separate file 2025-08-31 10:06:20 +02:00
dsehnal
966bc14c67 5.0.0-dev.11 2025-08-31 09:51:54 +02:00
David Sehnal
f752b7e155 MVS: Color map interpolation & canvas backgrounds (#1636)
* MVS: Color map interpolation

* print validation errors to plugin log

* fixes

* background postprocessing

* fix resetCanvasProps

* fix link

* add example
2025-08-31 09:36:50 +02:00
Gianluca Tomasello
255b8b9ac3 Fix renderer transparency check (#1635)
* Fix renderer transparency check

* Fading transparent outlines

* improvements
2025-08-29 17:59:26 +02:00
Victoria Doshchenko
42d969bbeb MVS: example story improvements (#1632)
* add intro scene

* fixes

* add author name
2025-08-26 18:55:34 +02:00
dsehnal
fdc33e44dc 5.0.0-dev.10 2025-08-26 17:43:06 +02:00
David Sehnal
b0aa889a0a MVS: Animation improvements (#1631)
* allow interpolation "keyframing"

* animation fixes
2025-08-26 17:41:21 +02:00
David Sehnal
4d7bd53231 Additional markdown commands (#1630) 2025-08-26 06:58:40 +02:00
David Sehnal
c11cf665c9 Additional markdown extensions (#1629)
* additional markdown extensions

* fixes
2025-08-25 20:12:10 +02:00
dsehnal
a4b09d3a0c 5.0.0-dev.9 2025-08-25 17:00:54 +02:00
David Sehnal
6e488b0f80 MotM1 Story tweaks (#1627)
* tweak story

* bugfixes & tweaks

* linting

* support "discrete" scalar transform

* tweak audio path

* tweak ui
2025-08-25 08:22:36 +02:00
ludovic autin
6164281a50 initial work on MOM number 1 with audio comments (#1624)
* initial work on MOM number 1 with audio comments

* add some TODO comments

* separate audio as mp3. Do coloring with DG scheme

* move audio in root example folder. test some query in mol-script

* salt bridge.

* better coloring

* support for entry-id test in MolScriptBuilder.

* lint

* update audio, sync animation

* cleanup

* clean up and sync audio/anim

* add reference to MOM1
2025-08-25 07:14:46 +02:00
Alexander Rose
2db7171e2a Merge pull request #1625 from molstar/trackball-state-tweaks
Trackball & Snapshot handling tweaks
2025-08-24 13:42:24 -07:00
Alexander Rose
edfc094952 re-add the !isBusy check
Co-authored-by: David Sehnal <dsehnal@users.noreply.github.com>
2025-08-24 13:35:37 -07:00
David Sehnal
b3e1e2900b Fix Markdown Commands query focus (#1626) 2025-08-24 13:18:36 +02:00
Alexander Rose
1e498d535a add SnapshotControls behavior 2025-08-23 10:44:50 -07:00
Alexander Rose
6ed969cd1b don't save behaviors in me snapshots 2025-08-23 10:42:05 -07:00
Alexander Rose
27bb4f4bca remove unused trackball param 2025-08-23 10:41:21 -07:00
Alexander Rose
6ce2139272 add canvas3d/trackball attribs
- attribs are configurable but are not saved in the state like props
2025-08-23 10:41:04 -07:00
dsehnal
13cf6613a6 fix typo 2025-08-23 19:18:21 +02:00
David Sehnal
c5bb13e295 Execute markdown commands on snapshot load (#1622) 2025-08-22 18:25:24 +02:00
dsehnal
34c8257848 5.0.0-dev.8 2025-08-22 16:05:31 +02:00
David Sehnal
fcbf39c935 Markdown & MVS: Audio support (#1621)
* audio playback markdown commands

* trigger markdown commands from MVS primitives

* docs

* fix usage of mutative
2025-08-22 16:00:30 +02:00
dsehnal
46c8150b2b 5.0.0-dev.7 2025-08-21 14:05:04 +02:00
David Sehnal
af1a864daa replace immer with mutative, optimize MVS animation loading (#1618) 2025-08-21 14:03:22 +02:00
David Sehnal
3babd9399a MVS: Add backbone and line representation types (#1617) 2025-08-20 17:33:03 +02:00
David Sehnal
e57564486f mol-state: fix param normalization (#1616)
* mol-state: fix param normalization

* headers
2025-08-19 19:33:01 +02:00
midlik
464a91ac29 Add PluginConfig.Viewport.ShowReset (#1615)
* Add PluginConfig.Viewport.ShowReset

* Update CHANGELOG
2025-08-18 12:02:31 +02:00
Alexander Rose
27fa50a5de Merge pull request #1610 from giagitom/dpoit-monolayer-transparency
Add Monolayer transparency (exploiting dpoit)
2025-08-16 14:37:34 -07:00
David Sehnal
1e323f18f7 MVS: additional formats and trajectory support (#1613)
* MVS: additional formats and trajectory support

* refactoring

* support lammpstrj

* tweaks

* remove test code
2025-08-15 20:10:13 +02:00
midlik
2685b2b77d MVSX use Murmur hash (#1612) 2025-08-15 14:11:53 +02:00
David Sehnal
d71b47a515 MVS Stories and related updates (#1609)
* mvs-stories updates, better snapshot playback if transition is present

* Add createMVSX

* mvs: support trackball animation

* tweak

* more fine grained speed of camera spin animation

* update TrackballControlsParams.animate.spin.speed

* story-session-url arg support
2025-08-15 13:27:34 +02:00
giagitom
88cc720dd2 fix render without postprocessing 2025-08-14 23:34:45 +02:00
giagitom
201433cc91 Lint fix 2025-08-14 19:53:36 +02:00
giagitom
8582303491 Add Monolayer transparency (exploiting dpoit) 2025-08-14 19:40:24 +02:00
dsehnal
655c3edadd 5.0.0-dev.6 2025-08-14 11:37:40 +02:00
David Sehnal
a4323a4bd8 MVS animation improvements (#1608) 2025-08-14 11:33:50 +02:00
98 changed files with 3126 additions and 545 deletions

View File

@@ -12,6 +12,8 @@ Note that since we don't clearly distinguish between a public and private interf
- [Breaking] Add `Volume.instances` support and a `VolumeInstances` transform to dynamically assign it
- This change is breaking because all volume objects require the `instances` field now.
- [Breaking] `Canvas3D.identify` now expects `Vec2` or `Ray3D`
- [Breaking] `TrackballControlsParams.animate.spin.speed` now means "Number of rotations per second" instead of "radians per second"
- [Breaking] `PluginStateSnapshotManager.play` now accepts an options object instead of a single boolean value
- Update production build to use `esbuild`
- Emit explicit paths in `import`s in `lib/`
- Fix outlines on opaque elements using illumination mode
@@ -19,9 +21,10 @@ Note that since we don't clearly distinguish between a public and private interf
- MolViewSpec extension:
- Generic color schemes (`palette` parameter for color_from_* nodes)
- Annotation field remapping (`field_remapping` parameter for color_from_* nodes)
- Representation node: support custom property `molstar_reprepresentation_params`
- Primitives node: support custom property `molstar_mesh/label/line_params`
- Canvas node: support custom property `molstar_postprocessing` with the ability to customize outline, depth of field, bloom, shadow, occlusion (SSAO), and fog
- `representation` node: support custom property `molstar_representation_params`
- Add `backbone` and `line` representation types
- `primitives` node: support custom property `molstar_mesh/label/line_params`
- `canvas` node: support custom property `molstar_postprocessing` with the ability to customize outline, depth of field, bloom, shadow, occlusion (SSAO), fog, and background
- `clip` node support for structure and volume representations
- `grid_slice` representation support for volumes
- Support tethers and background for primitive labels
@@ -34,14 +37,24 @@ Note that since we don't clearly distinguish between a public and private interf
- Support transforming and instancing of structures, components, and volumes
- Use params hash for node version for more performant tree diffs
- Add `Snapshot.animation` support that enables animating almost every property in a given tree
- Add `createMVSX` helper function
- Support Mol* trackball animation via `animation.custom.molstar_trackball`
- MVSX - use Murmur hash instead of FNV in archive URI
- Support additional file formats (pdbqt, gro, xyz, mol, sdf, mol2, xtc, lammpstrj)
- Support loading trajectory coordinates from separate nodes
- Trigger markdown commands from primitives using `molstar_markdown_commands` custom extensions
- Support `molstar_on_load_markdown_commands` custom state on the `root` node
- Print tree validation errors to plugin log
- Added new color schemes, synchronized with D3.js ('inferno', 'magma', 'turbo', 'rainbow', 'sinebow', 'warm', 'cool', 'cubehelix-default', 'category-10', 'observable-10', 'tableau-10')
- Snapshot Markdown improvements
- Add `MarkdownExtensionManager` (`PluginContext.managers.markdownExtensions`)
- Support custom markdown commands to control the plugin via the `[link](!command)` pattern
- Support rendering custom elements via the `![alt](!parameters)` pattern
- Support tables
- Support loading images from MVSX files
- Support loading images and audio from MVSX files
- Indicate external links with ⤴
- Audio support
- Add `PluginState.Snapshot.onLoadMarkdownCommands`
- Avoid calculating rings for coarse-grained structures
- Fix isosurface compute shader normals when transformation matrix is applied to volume
- Symmetry operator naming for spacegroup symmetry - parenthesize multi-character indices (1_111-1 -> 1_(11)1(-1))
@@ -57,7 +70,10 @@ Note that since we don't clearly distinguish between a public and private interf
- Add `StructureInstances` transform
- `mvs-stories` app
- Add `story-id` URL arg support
- Add `story-session-url` URL arg support
- Add "Download MVS State" link
- Add "Open in Mol*" link
- Add "Edit in MolViewStories" link for story states
- Add ray-based picking
- Render narrow view of scene scene from ray origin & direction to a few pixel sized viewport
- Cast ray on every input as opposed to the standard "whole screen" picking
@@ -81,6 +97,14 @@ Note that since we don't clearly distinguish between a public and private interf
- Add `Hsl` and (normalized) `Rgb` color spaces
- Add `Color.interpolateHsl`
- Add `rotationCenter` property to `TransformParam`
- Add Monolayer transparency (exploiting dpoit).
- Add plugin config item ShowReset (shows/hides "Reset Zoom" button)
- Fix transform params not being normalized when used together with param hash version
- Replace `immer` with `mutative`
- Fix renderer transparency check
- VolumeServer & "VolumeCIF": default to P 1 spacegroup
- Fix `ColorScale` for continuous case without offsets (broke in v4.13.0)
- Experimental: support for custom color themes in Sequence Panel
## [v4.18.0] - 2025-06-08
- MolViewSpec extension:

View File

@@ -20,14 +20,19 @@ Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `en
- `center-camera` - Centers the camera
- `apply-snapshot=key` - Loads snapshots with the provided key
- `next-snapshot[=-1|1]` - Loads next/previous snapshot, the direction is optional and default to `1`
- `play-snapshots` - Starts playback of state snapshots
- `play-transition` - Plays an animation associated with the given snapshot
- `stop-animation` - Stops currently playing animation
- `focus-refs=ref1,ref2,...` - On click, focuses nodes with the provided refs
- `highlight-refs=ref1,ref2,...` - On mouse over, highlights the provided refs
- `query=...&lang=...&action=highlight,focus&focus-radius=...`
- `query` is an expression (e.g., `resn HEM` when using PyMol syntax)
- (optional) `lang` is one of `mol-script` (default), `pymol`, `vmd`, `jmol`
- (optional) `action` is an array of `highlight` (default), `focus` (multiple actions can be specified)
- (optional) `focus-radius` is extra distance applied when focusing the selection (default is `3`)
- Example: `[HEM](!query%3Dresn%20HEM%26lang%3Dpymol%26action%3Dhighlight%2Cfocus)` highlights or focuses the HEM residue (the command must be URL encoded because it contains spaces and possibly other special characters)
- `query` is an expression (e.g., `resn HEM` when using PyMol syntax)
- (optional) `lang` is one of `mol-script` (default), `pymol`, `vmd`, `jmol`
- (optional) `action` is an array of `highlight` (default), `focus` (multiple actions can be specified)
- (optional) `focus-radius` is extra distance applied when focusing the selection (default is `3`)
- Example: `[HEM](!query=resn%20HEM%26lang=pymol&action=highlight,focus)` highlights or focuses the HEM residue (the query must be URL encoded because it contains spaces and possibly other special characters)
- `play-audio=src`, `toggle-audio[=src]`, `stop-audio`, `pause-audio`, `dispose-audio` - Audio playback support
## Custom Content
@@ -36,11 +41,11 @@ Extends Markdown Image syntax to support expressions of the form `![alt](!c1=v1&
### Built-in Custom Content
- `color-swatch=color` - Renders a box with the provided color
- Color palettes:
- `color-palette-name=name` - Renders a gradient with the provided named color palette (see `mol-util/color/lists.ts` for supported color schemes)
- `color-palette-colors=color1,color2` - Renders a gradient with the provided colors
- `color-palette-width=CCS-value` - Specifies the width of the element, defaults to `150px`
- `color-palette-height=CCS-value` - Specified the height of the element, defaults to `0.5em`
- `color-palette-discrete` - Renders discrete color list instead of interpolating
- `color-palette-name=name` - Renders a gradient with the provided named color palette (see `mol-util/color/lists.ts` for supported color schemes)
- `color-palette-colors=color1,color2` - Renders a gradient with the provided colors
- `color-palette-width=CCS-value` - Specifies the width of the element, defaults to `150px`
- `color-palette-height=CCS-value` - Specified the height of the element, defaults to `0.5em`
- `color-palette-discrete` - Renders discrete color list instead of interpolating
## Example

View File

@@ -39,6 +39,7 @@ export default defineConfig([{
"comma-spacing": "off",
"space-infix-ops": "off",
"comma-dangle": "off",
quotes: ["warn", "single", { "allowTemplateLiterals": true, "avoidEscape": true }],
eqeqeq: ["error", "smart"],
"import/order": "off",
"no-eval": "warn",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "molstar",
"version": "5.0.0-dev.5",
"version": "5.0.0-dev.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "molstar",
"version": "5.0.0-dev.5",
"version": "5.0.0-dev.13",
"license": "MIT",
"dependencies": {
"@types/argparse": "^2.0.17",
@@ -21,9 +21,9 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"h264-mp4-encoder": "^1.0.12",
"immer": "^10.1.1",
"immutable": "^5.1.2",
"io-ts": "^2.2.22",
"mutative": "^1.2.0",
"node-fetch": "^2.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -5557,15 +5557,6 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
@@ -7990,6 +7981,15 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/mutative": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mutative/-/mutative-1.2.0.tgz",
"integrity": "sha512-1muFw45Lwjso6TSBGiXfbjKS01fVSD/qaqBfTo/gXgp79e8KM4Sa1XP/S4iN2/DvSdIZgjFJI+JIhC7eKf3GTg==",
"license": "MIT",
"engines": {
"node": ">=14.0"
}
},
"node_modules/mylas": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "molstar",
"version": "5.0.0-dev.5",
"version": "5.0.0-dev.13",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {
@@ -122,7 +122,8 @@
"Lukáš Polák <admin@lukaspolak.cz>",
"Chetan Mishra <chetan.s115@gmail.com>",
"Zach Charlop-Powers <zach.charlop.powers@gmail.com>",
"Kim Juho <juho_kim@outlook.com>"
"Kim Juho <juho_kim@outlook.com>",
"Victoria Doshchenko <doshchenko.victoria@gmail.com>"
],
"license": "MIT",
"devDependencies": {
@@ -166,9 +167,9 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"h264-mp4-encoder": "^1.0.12",
"immer": "^10.1.1",
"immutable": "^5.1.2",
"io-ts": "^2.2.22",
"mutative": "^1.2.0",
"node-fetch": "^2.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",

View File

@@ -131,8 +131,8 @@ function getPaths(app) {
async function createBundle(app) {
const { name, kind } = app;
const { prefix, entry, outfile } = getPaths(app);
const NODE_ENV_PRD = isProduction || process.env.NODE_ENV === 'production';
const ctx = await esbuild.context({
entryPoints: [entry],
@@ -161,6 +161,7 @@ async function createBundle(app) {
color: true,
logLevel: 'info',
define: {
'process.env.NODE_ENV': JSON.stringify(NODE_ENV_PRD ? 'production' : 'development'),
'process.env.DEBUG': JSON.stringify(process.env.DEBUG || false),
__MOLSTAR_PLUGIN_VERSION__: JSON.stringify(VERSION),
__MOLSTAR_BUILD_TIMESTAMP__: `${TIMESTAMP}`,

View File

@@ -7,14 +7,14 @@
const git = require('simple-git');
const path = require('path');
const fs = require("fs");
const fse = require("fs-extra");
const fs = require('fs');
const fse = require('fs-extra');
const argparse = require('argparse');
const VERSION = require(path.resolve(__dirname, '../package.json')).version;
const MVS_STORIES_VERSION = require(path.resolve(__dirname, '../src/apps/mvs-stories/version.ts')).VERSION;
const remoteUrl = "https://github.com/molstar/molstar.github.io.git";
const remoteUrl = 'https://github.com/molstar/molstar.github.io.git';
const dataDir = path.resolve(__dirname, '../data/');
const buildDir = path.resolve(__dirname, '../build/');
const deployDir = path.resolve(__dirname, '../deploy/');

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2022-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2022-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -147,6 +147,7 @@ export class MesoscaleExplorer {
behaviors: [
PluginSpec.Behavior(PluginBehaviors.Camera.CameraAxisHelper),
PluginSpec.Behavior(PluginBehaviors.Camera.CameraControls),
PluginSpec.Behavior(PluginBehaviors.State.SnapshotControls),
PluginSpec.Behavior(MesoFocusLoci),
PluginSpec.Behavior(MesoSelectLoci),
@@ -261,7 +262,6 @@ export class MesoscaleExplorer {
image: true,
componentManager: false,
structureSelection: true,
behavior: true,
});
plugin.managers.lociLabels.clearProviders();

View File

@@ -53,6 +53,10 @@
text-decoration: none;
}
#links .sep {
color: #aaa;
}
@media (orientation:portrait) {
#viewer {
position: absolute;
@@ -90,13 +94,16 @@
</div>
<div id="links">
<a href="#" id="mvs-data">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/apps/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
<span id="open-in-stories"><a href="#" id="open-in-stories-link" target="_blank" rel="noopener noreferrer" title="Open and edit the story in the MolViewStories app">Edit in MolViewStories</a>&nbsp;<span class="sep"></span></span>
<span id="open-in-molstar"><a href="#" id="open-in-molstar-link" target="_blank" rel="noopener noreferrer" title="Open the story in the Mol* Viewer app. Enables exporting an animation.">Open in Mol* Viewer</a>&nbsp;<span class="sep"></span></span>
<a href="#" id="mvs-data" title="MolViewSpec State for this story. Can be opened in the Mol* app.">Download MVS</a> <span class="sep"></span> <a href="https://github.com/molstar/molstar/tree/master/src/apps/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
</div>
<script>
var urlParams = new URLSearchParams(window.location.search);
var storyId = urlParams.get('story-id');
var storyUrl = urlParams.get('story-url');
var storySessionUrl = urlParams.get('story-session-url');
var format = urlParams.get('data-format');
// For testing purposes:
@@ -104,12 +111,32 @@
// storyUrl = 'https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/kinase-story.mvsj';
// }
var molstarDataLink = storyUrl;
var editInStoriesUrl = undefined;
if (storyId) {
mvsStories.loadFromID(storyId, { format: format || 'mvsj', contextName: 'story1' });
editInStoriesUrl = 'https://molstar.org/mol-view-stories/builder?published-session-id=' + storyId;
molstarDataLink = 'https://stories.molstar.org/api/story/' + storyId + '/data';
} else if (storyUrl) {
mvsStories.loadFromURL(storyUrl, { format: format || 'mvsj', contextName: 'story1' });
}
if (!editInStoriesUrl && storySessionUrl) {
editInStoriesUrl = 'https://molstar.org/mol-view-stories/builder?session-url=' + encodeURIComponent(storySessionUrl);
}
if (molstarDataLink) {
var molstarLink = 'https://molstar.org/viewer?mvs-url=' + encodeURIComponent(molstarDataLink) + '&mvs-format=' + encodeURIComponent(format || 'mvsj');
document.getElementById('open-in-molstar-link').setAttribute('href', molstarLink);
document.getElementById('open-in-molstar').style.display = 'inline';
}
if (editInStoriesUrl) {
document.getElementById('open-in-stories-link').setAttribute('href', editInStoriesUrl);
document.getElementById('open-in-stories').style.display = 'inline';
}
document.getElementById('mvs-data').addEventListener('click', (e) => {
e.preventDefault();
mvsStories.downloadCurrentStory({ contextName: 'story1' });

View File

@@ -182,6 +182,15 @@
max-width: 100%;
height: auto;
}
a {
text-decoration: none;
color: #1d4ed7;
&:hover {
text-decoration: underline;
}
}
}
@media (orientation:portrait) {

View File

@@ -109,8 +109,10 @@ const DefaultViewerOptions = {
resolutionMode: PluginConfig.General.ResolutionMode.defaultValue,
illumination: false,
viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
viewportShowReset: PluginConfig.Viewport.ShowReset.defaultValue,
viewportShowScreenshotControls: PluginConfig.Viewport.ShowScreenshotControls.defaultValue,
viewportShowControls: PluginConfig.Viewport.ShowControls.defaultValue,
viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
viewportShowSettings: PluginConfig.Viewport.ShowSettings.defaultValue,
viewportShowSelectionMode: PluginConfig.Viewport.ShowSelectionMode.defaultValue,
viewportShowAnimation: PluginConfig.Viewport.ShowAnimation.defaultValue,
@@ -187,8 +189,10 @@ export class Viewer {
[PluginConfig.General.AllowMajorPerformanceCaveat, o.allowMajorPerformanceCaveat],
[PluginConfig.General.PowerPreference, o.powerPreference],
[PluginConfig.General.ResolutionMode, o.resolutionMode],
[PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
[PluginConfig.Viewport.ShowReset, o.viewportShowReset],
[PluginConfig.Viewport.ShowScreenshotControls, o.viewportShowScreenshotControls],
[PluginConfig.Viewport.ShowControls, o.viewportShowControls],
[PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
[PluginConfig.Viewport.ShowSettings, o.viewportShowSettings],
[PluginConfig.Viewport.ShowSelectionMode, o.viewportShowSelectionMode],
[PluginConfig.Viewport.ShowAnimation, o.viewportShowAnimation],

View File

@@ -11,7 +11,7 @@
*/
import { ArgumentParser } from 'argparse';
import { treeSchemaToMarkdown, treeSchemaToString } from '../../extensions/mvs/tree/generic/tree-schema';
import { treeSchemaToMarkdown, treeSchemaToString } from '../../extensions/mvs/tree/generic/tree-validation';
import { MVSTreeSchema } from '../../extensions/mvs/tree/mvs/mvs-tree';

View File

@@ -22,7 +22,15 @@ const Steps = [
header: 'Animation Demo',
key: 'intro',
description: `### Molecular Animation
A story showcasing MolViewSpec animation capabilities.`,
A story showcasing MolViewSpec animation capabilities.
[\[**🔄 Replay Intro**\]](!play-transition)
[\[**⏵ Play Snapshots**\]](!play-snapshots)
[\[**⏹ Stop Animation**\]](!stop-animation)
[\[**➡️ Next Snapshot**\]](!next-snapshot)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
state: (): Root => {
@@ -37,16 +45,35 @@ A story showcasing MolViewSpec animation capabilities.`,
});
prims.label({ text: 'Animation Demo', position: { label_asym_id: 'A' }, label_size: 10 });
const anim = builder.animation();
const anim = builder.animation({
custom: {
molstar_trackball: {
name: 'rock',
params: { speed: 0.5 },
}
}
});
anim.interpolate({
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
duration_ms: 1000,
start_ms: 500,
duration_ms: 500,
property: 'label_opacity',
end: 1,
});
anim.interpolate({
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
start_ms: 1500,
duration_ms: 500,
property: 'label_opacity',
start: 1,
end: 0.66,
});
// Uncomment this to make 2nd frame render much faster
// It will cause shader compilation to happen during the 1st snapshot
@@ -84,12 +111,28 @@ A story showcasing MolViewSpec animation capabilities.`,
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
const [poly,] = polymer(_1cbs, { color: Colors['1cbs'] });
const [poly, repr] = polymer(_1cbs, { color: Colors['1cbs'] });
repr.colorFromSource({
ref: 'residue_colors',
schema: 'residue',
category_name: 'atom_site',
field_name: 'label_comp_id',
palette: {
kind: 'categorical',
missing_color: 'white',
colors: {
ALA: 'red',
ILE: 'white',
LYS: 'white',
}
}
});
const surface = poly.representation({
type: 'surface',
surface_type: 'gaussian',
});
}).opacity({ opacity: 0.33 });
_1cbs.component({ selector: 'ligand' })
.transform({
@@ -112,14 +155,32 @@ A story showcasing MolViewSpec animation capabilities.`,
anim.interpolate({
kind: 'scalar',
ref: 'clip-transition',
target_ref: 'clip',
duration_ms: 2000,
duration_ms: 500,
property: ['point', 2],
end: 55,
easing: 'sin-in',
});
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
start_ms: 600,
duration_ms: 800,
property: ['point', 2],
end: 0,
easing: 'sin-out',
});
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
start_ms: 1500,
duration_ms: 500,
property: ['point', 2],
end: 55,
});
anim.interpolate({
kind: 'vec3',
target_ref: 'xform',
@@ -145,6 +206,20 @@ A story showcasing MolViewSpec animation capabilities.`,
end: Colors['ligand-docked'],
});
anim.interpolate({
kind: 'color',
target_ref: 'residue_colors',
duration_ms: 2000,
property: ['palette', 'colors'],
start: {
ALA: 'yellow',
},
end: {
ILE: 'blue',
LYS: 'purple',
},
});
return builder;
},
camera: {
@@ -175,7 +250,7 @@ A story showcasing MolViewSpec animation capabilities.`,
ref: 'repr',
type: 'ball_and_stick',
custom: {
molstar_reprepresentation_params: {
molstar_representation_params: {
emissive: 0,
}
}
@@ -202,7 +277,7 @@ A story showcasing MolViewSpec animation capabilities.`,
kind: 'scalar',
target_ref: 'repr',
duration_ms: 1000,
property: ['custom', 'molstar_reprepresentation_params', 'emissive'],
property: ['custom', 'molstar_representation_params', 'emissive'],
end: 0.2,
});
@@ -266,10 +341,12 @@ function structure(builder: Root, id: string): MVSStructure {
.modelStructure();
}
function polymer(structure: MVSStructure, options: { color: ColorT }) {
function polymer(structure: MVSStructure, options?: { color?: ColorT }) {
const component = structure.component({ selector: { label_asym_id: 'A' } });
const reprensentation = component.representation({ type: 'cartoon' });
reprensentation.color({ color: options.color });
if (options?.color) {
reprensentation.color({ color: options.color });
}
return [component, reprensentation] as const;
}
@@ -287,6 +364,21 @@ export function buildStory(): MVSData_States {
molstar_postprocessing: {
enable_outline: true,
enable_ssao: true,
background: {
name: 'horizontalGradient',
params: {
topColor: 0x777777,
bottomColor: 0xffffff,
}
},
// Example with background image:
// background: {
// name: 'image',
// params: {
// // URL can also be filename in MVSX archive
// source: { name: 'url', params: 'URL' }
// }
// }
}
}
});

File diff suppressed because one or more lines are too long

View File

@@ -7,9 +7,13 @@
import { buildStory as kinase } from './kinase';
import { buildStory as tbp } from './tbp';
import { buildStory as animation } from './animation';
import { buildStory as audio } from './audio';
import { buildStory as motm1 } from './motm1';
export const Stories = [
{ id: 'kinase', name: 'BCR-ABL: A Kinase Out of Control', buildStory: kinase },
{ id: 'tata', name: 'TATA-Binding Protein and its Role in Transcription Initiation ', buildStory: tbp },
{ id: 'animation', name: 'Molecular Animation', buildStory: animation },
{ id: 'motm1', name: 'RCSB Molecule of the Month #1', buildStory: motm1 },
{ id: 'animation-example', name: 'Molecular Animation Example', buildStory: animation },
{ id: 'audio-example', name: 'Audio Playback Example', buildStory: audio },
] as const;

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
Find the MVS extension documentation [here](../../../docs/extensions/mvs/README.md).
Please refer to the standalone documentation [here](https://molstar.org/mol-view-spec-docs/).

View File

@@ -7,6 +7,8 @@
import { Camera } from '../../mol-canvas3d/camera';
import { CameraFogParams, Canvas3DParams, Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
import { TrackballControlsParams } from '../../mol-canvas3d/controls/trackball';
import { BackgroundParams } from '../../mol-canvas3d/passes/background';
import { BloomParams } from '../../mol-canvas3d/passes/bloom';
import { DofParams } from '../../mol-canvas3d/passes/dof';
import { OutlineParams } from '../../mol-canvas3d/passes/outline';
@@ -20,10 +22,12 @@ import { PluginState } from '../../mol-plugin/state';
import { StateObjectSelector } from '../../mol-state';
import { fovAdjustedPosition } from '../../mol-util/camera';
import { ColorNames } from '../../mol-util/color/names';
import { deepClone } from '../../mol-util/object';
import { ParamDefinition } from '../../mol-util/param-definition';
import { decodeColor } from './helpers/utils';
import { MolstarLoadingContext } from './load';
import { SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { MolstarNode, MolstarNodeParams } from './tree/molstar/molstar-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
@@ -121,22 +125,22 @@ export function createPluginStateSnapshotCamera(plugin: PluginContext, context:
return camera;
}
/** Set canvas properties based on a canvas node. */
export function setCanvas(plugin: PluginContext, node: MolstarNode<'canvas'> | undefined) {
plugin.canvas3d?.setProps(old => modifyCanvasProps(old, node));
}
function optionalParams(enable: boolean | undefined, values: any, params: ParamDefinition.Params, fallback: any) {
if (typeof enable === 'boolean') {
return enable
? { name: 'on', params: { ...ParamDefinition.getDefaultValues(params), ...values } }
: { name: 'off', params: { } };
: { name: 'off', params: {} };
}
return fallback;
}
function normalizeBackground(variant: any, prev: any): any {
if (!variant) return prev;
return ParamDefinition.normalizeParams(BackgroundParams, { variant }, 'children');
}
/** Create a deep copy of `oldCanvasProps` with values modified according to a canvas node params. */
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, custom?: Record<string, any>): Canvas3DProps {
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, animationNode: MVSAnimationNode<'animation'> | undefined): Canvas3DProps {
const params = canvasNode?.params;
const backgroundColor = decodeColor(params?.background_color) ?? DefaultCanvasBackgroundColor;
@@ -160,6 +164,12 @@ export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: Mol
const bloom = molstar_postprocessing?.enable_bloom;
const bloomParams = molstar_postprocessing?.bloom_params;
const background = molstar_postprocessing?.background;
const trackballAnimation = animationNode?.custom?.molstar_trackball;
const trackballAnimationName = trackballAnimation?.name;
const trackballAnimationParams = trackballAnimation?.params ?? {};
return {
...oldCanvasProps,
postprocessing: {
@@ -169,12 +179,28 @@ export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: Mol
occlusion: optionalParams(occlusion, occlusionParams, SsaoParams, oldCanvasProps.postprocessing.occlusion),
dof: optionalParams(dof, dofParams, DofParams, oldCanvasProps.postprocessing.dof),
bloom: optionalParams(bloom, bloomParams, BloomParams, oldCanvasProps.postprocessing.bloom),
background: normalizeBackground(background, oldCanvasProps.postprocessing.background),
},
cameraFog: optionalParams(fog, fogParams, CameraFogParams, oldCanvasProps.cameraFog),
renderer: {
...oldCanvasProps.renderer,
backgroundColor: backgroundColor,
},
trackball: {
...oldCanvasProps?.trackball,
...(trackballAnimationName
? {
animate: {
name: trackballAnimationName,
params: {
...TrackballControlsParams.animate.map(trackballAnimationName)?.defaultValue,
...trackballAnimationParams
}
}
}
: {}
),
}
};
}
@@ -184,12 +210,17 @@ export function resetCanvasProps(plugin: PluginContext) {
...old,
postprocessing: {
...old,
outline: DefaultCanvas3DParams.postprocessing.outline,
shadow: DefaultCanvas3DParams.postprocessing.shadow,
occlusion: DefaultCanvas3DParams.postprocessing.occlusion,
dof: DefaultCanvas3DParams.postprocessing.dof,
bloom: DefaultCanvas3DParams.postprocessing.bloom,
outline: deepClone(DefaultCanvas3DParams.postprocessing.outline),
shadow: deepClone(DefaultCanvas3DParams.postprocessing.shadow),
occlusion: deepClone(DefaultCanvas3DParams.postprocessing.occlusion),
dof: deepClone(DefaultCanvas3DParams.postprocessing.dof),
bloom: deepClone(DefaultCanvas3DParams.postprocessing.bloom),
background: deepClone(DefaultCanvas3DParams.postprocessing.background),
},
cameraFog: DefaultCanvas3DParams.cameraFog,
cameraFog: deepClone(DefaultCanvas3DParams.cameraFog),
trackball: {
...old?.trackball,
animate: { name: 'off', params: {} },
}
});
}

View File

@@ -5,7 +5,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { hashFnv32a } from '../../../mol-data/util';
import { murmurHash3_128_fromBytes } from '../../../mol-data/util';
import { StringLike } from '../../../mol-io/common/string-like';
import { DataFormatProvider } from '../../../mol-plugin-state/formats/provider';
import { PluginStateObject as SO } from '../../../mol-plugin-state/objects';
@@ -118,7 +118,7 @@ export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext
// states.
clearMVSXFileAssets(plugin);
const archiveId = `ni,fnv1a;${hashFnv32a(data)}`;
const archiveId = `ni,MurmurHash3_128;${murmurHash3_128_fromBytes(data, 42)}`;
let files: { [path: string]: Uint8Array };
try {
files = await unzip(runtimeCtx, data) as typeof files;
@@ -182,7 +182,7 @@ function tryGetDownloadUrl(pso: SO.Data.String, plugin: PluginContext): string |
}
/** Return a URI referencing a file within an archive, using ARCP scheme (https://arxiv.org/pdf/1809.06935.pdf).
* `archiveId` corresponds to the `authority` part of URI (e.g. 'uuid,EYVwjDiZhM20PWbF1OWWvQ' or 'ni,fnv1a;938511930')
* `archiveId` corresponds to the `authority` part of URI (e.g. 'uuid,EYVwjDiZhM20PWbF1OWWvQ' or 'ni,MurmurHash3_128;e6494f6be71f34c556f3de73d306780c')
* `path` corresponds to the path to a file within the archive */
function arcpUri(archiveId: string, path: string): string {
return new URL(path, `arcp://${archiveId}/`).href;

View File

@@ -149,6 +149,7 @@ export const MVSBuildPrimitiveShape = MVSTransform({
const context: PrimitiveBuilderContext = { ...a.data, structureRefs };
const snapshotKey = { snapshotKey: { ...SnapshotKey, defaultValue: a.data.options?.snapshot_key ?? '' } };
const markdownCommands = { markdownCommands: { ...MarkdownCommands, defaultValue: a.data.node?.custom?.molstar_markdown_commands } };
const label = capitalize(params.kind);
if (params.kind === 'mesh') {
@@ -161,6 +162,7 @@ export const MVSBuildPrimitiveShape = MVSTransform({
params: {
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1, ...customMeshParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveMesh(data, prev?.geometry),
geometryUtils: Mesh.Utils,
@@ -185,6 +187,7 @@ export const MVSBuildPrimitiveShape = MVSTransform({
...customLabelParams,
}),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, props, prev: any) => buildPrimitiveLabels(data, prev?.geometry, props),
geometryUtils: Text.Utils,
@@ -199,6 +202,7 @@ export const MVSBuildPrimitiveShape = MVSTransform({
params: {
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1, ...customLineParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveLines(data, prev?.geometry),
geometryUtils: Lines.Utils,
@@ -227,7 +231,7 @@ export const MVSShapeRepresentation3D = MVSTransform({
const repr = ShapeRepresentation(a.data.getShape, a.data.geometryUtils);
await repr.createOrUpdate(props, a.data.data).runInContext(ctx);
const pickable = !!(params as any).snapshotKey?.trim();
const pickable = !!(params as any).snapshotKey?.trim() || !!(params as any).markdownCommands;
if (pickable) {
repr.setState({ pickable, markerActions: MarkerActions.Highlighting });
}
@@ -241,7 +245,7 @@ export const MVSShapeRepresentation3D = MVSTransform({
await b.data.repr.createOrUpdate(props, a.data.data).runInContext(ctx);
b.data.sourceData = a.data;
const pickable = !!(newParams as any).snapshotKey?.trim();
const pickable = !!(newParams as any).snapshotKey?.trim() || !!(newParams as any).markdownCommands;
if (pickable) {
b.data.repr.setState({ pickable, markerActions: MarkerActions.Highlighting });
}
@@ -252,6 +256,7 @@ export const MVSShapeRepresentation3D = MVSTransform({
});
const SnapshotKey = PD.Text('', { isEssential: true, disableInteractiveUpdates: true, description: 'Activate the snapshot with the provided key when clicking on the label' });
const MarkdownCommands = PD.Value<any>(undefined, { isHidden: true });
/* **************************************************** */

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { PluginStateObject } from '../../../mol-plugin-state/objects';
import { getTrajectory } from '../../../mol-plugin-state/transforms/model';
import { Task } from '../../../mol-task';
import { ParamDefinition } from '../../../mol-util/param-definition';
import { getMVSReferenceObject } from '../helpers/utils';
import { MVSTransform } from './annotation-structure-component';
export const MVSTrajectoryWithCoordinates = MVSTransform({
name: 'trajectory-with-coordinates',
display: { name: 'Trajectory with Coordinates', description: 'Create a trajectory from existing model and the provided coordinates.' },
from: PluginStateObject.Molecule.Model,
to: PluginStateObject.Molecule.Trajectory,
params: {
coordinatesRef: ParamDefinition.Text('', { isHidden: true }),
}
})({
apply({ a, params, dependencies }) {
return Task.create('Create trajectory from model/topology and coordinates', async ctx => {
const coordinates = getMVSReferenceObject([PluginStateObject.Molecule.Coordinates], dependencies, params.coordinatesRef);
if (!coordinates) {
throw new Error('Coordinates not found.');
}
const trajectory = await getTrajectory(ctx, a, coordinates.data);
const props = { label: 'Trajectory', description: `${trajectory.frameCount} model${trajectory.frameCount === 1 ? '' : 's'}` };
return new PluginStateObject.Molecule.Trajectory(trajectory, props);
});
}
});

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Zip } from '../../mol-util/zip/zip';
import { MVSData } from './mvs-data';
/**
* Creates an MVSX zip file with from the provided data and assets
*/
export async function createMVSX(data: MVSData, assets: { name: string, content: string | Uint8Array }[]) {
const encoder = new TextEncoder();
const files: Record<string, Uint8Array> = {
'index.mvsj': encoder.encode(JSON.stringify(data)),
};
for (const asset of assets) {
files[asset.name] = typeof asset.content === 'string'
? encoder.encode(asset.content)
: asset.content;
}
const zip = await Zip(files).run();
return new Uint8Array(zip);
}

View File

@@ -5,24 +5,25 @@
* @author Ludovic Autin <ludovic.autin@gmail.com>
*/
import { produce } from 'immer';
import { Snapshot } from '../mvs-data';
import { Tree } from '../tree/generic/tree-schema';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { addDefaults } from '../tree/generic/tree-utils';
import { RuntimeContext } from '../../../mol-task';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { EPSILON, Mat3, Mat4, Quat, Vec3 } from '../../../mol-math/linear-algebra';
import { RuntimeContext } from '../../../mol-task';
import { deepEqual } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { decodeColor } from '../../../mol-util/color/utils';
import { produce } from '../../../mol-util/produce';
import { makeContinuousPaletteCheckpoints, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from '../components/annotation-color-theme';
import { palettePropsFromMVSPalette } from '../load-helpers';
import { SortedArray } from '../../../mol-data/int';
import { Snapshot } from '../mvs-data';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { Tree } from '../tree/generic/tree-schema';
import { addDefaults } from '../tree/generic/tree-utils';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { ColorT } from '../tree/mvs/param-types';
export async function generateStateTransition(ctx: RuntimeContext, snapshot: Snapshot) {
export async function generateStateTransition(ctx: RuntimeContext, snapshot: Snapshot, snapshotIndex: number, snapshotCount: number) {
if (!snapshot.animation) return undefined;
const tree = addDefaults(snapshot.animation, MVSAnimationSchema);
@@ -34,19 +35,30 @@ export async function generateStateTransition(ctx: RuntimeContext, snapshot: Sna
...transitions.map(t => (t.params.start_ms ?? 0) + t.params.duration_ms)
);
const frames: MVSTree[] = [];
const frames: [tree: MVSTree, time: number][] = [];
const dt = tree.params?.frame_time_ms ?? (1000 / 60);
const N = Math.ceil(duration / dt);
const nodeMap = makeNodeMap(snapshot.root, new Map(), []);
const cache = new Map<any, InterpolationCacheEntry>();
const transitionGroups = groupTranstions(transitions);
let prevRoot: MVSTree | undefined;
for (let i = 0; i <= N; i++) {
const t = i * dt;
const root = createSnapshot(snapshot.root, transitions, t, cache);
frames.push(root);
const root = createSnapshot(snapshot.root, transitionGroups, t, cache, nodeMap);
if (root === prevRoot || (prevRoot && deepEqual(root, prevRoot))) {
frames[frames.length - 1][1] += dt;
} else {
frames.push([root, dt]);
}
prevRoot = root;
if (ctx.shouldUpdate) {
await ctx.update({ message: 'Generating transition...' });
await ctx.update({ message: `Generating transition for snapshot ${snapshotIndex + 1}/${snapshotCount}`, current: i + 1, max: N });
}
}
@@ -77,73 +89,96 @@ const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = {
interface InterpolationCacheEntry {
paletteFn?: (value: number) => Color,
startColor?: Color | Record<number | string, Color>,
endColor?: Color | Record<number | string, Color>,
rotation?: { axis: Vec3, angle: number, start: Quat, end: Quat },
}
function createSnapshot(tree: MVSTree, transitions: MVSAnimationNode<'interpolate'>[], time: number, cache: Map<any, InterpolationCacheEntry>) {
return produce(tree, (draft) => {
for (const transition of transitions) {
const node = findNode(draft, transition.params.target_ref);
if (!node) continue;
function getTransitionKey(transition: MVSAnimationNode<'interpolate'>) {
const prop = transition.params.property;
if (Array.isArray(prop)) {
return `${transition.params.target_ref}:${prop.join('.')}`;
}
return `${transition.params.target_ref}:${prop}`;
}
const target = transition.params.property[0] === 'custom' ? node?.custom : node?.params;
function groupTranstions(transitions: MVSAnimationNode<'interpolate'>[]) {
const map = new Map<string, MVSAnimationNode<'interpolate'>[]>();
const groups: MVSAnimationNode<'interpolate'>[][] = [];
for (const t of transitions) {
const key = getTransitionKey(t);
if (!map.has(key)) {
const group: MVSAnimationNode<'interpolate'>[] = [];
map.set(key, group);
groups.push(group);
}
map.get(key)!.push(t);
}
for (const group of groups) {
group.sort((a, b) => {
const s = (a.params.start_ms ?? 0) - (b.params.start_ms ?? 0);
if (s !== 0) return s;
return a.params.duration_ms - b.params.duration_ms;
});
}
return groups;
}
function createSnapshot(tree: MVSTree, transitionGroups: MVSAnimationNode<'interpolate'>[][], time: number, cache: Map<any, InterpolationCacheEntry>, nodeMap: Map<string, (string | number)[]>) {
let modified = false;
const ret = produce(tree, (draft) => {
for (const transitionGroup of transitionGroups) {
const pivot = transitionGroup[0];
const nodePath = nodeMap.get(pivot.params.target_ref);
if (!nodePath) continue;
const node = select(draft, nodePath, 0);
const target = pivot.params.property[0] === 'custom' ? node?.custom : node?.params;
if (!target) continue;
const offset = pivot.params.property[0] === 'custom' ? 1 : 0;
let transition: MVSAnimationNode<'interpolate'> = pivot;
let previous: MVSAnimationNode<'interpolate'> | undefined;
for (let i = transitionGroup.length - 1; i > 0; i--) {
const current = transitionGroup[i];
const currentStart = current.params.start_ms ?? 0;
if (time >= currentStart) {
transition = current;
previous = i > 0 ? transitionGroup[i - 1] : undefined;
break;
}
}
if (!cache.has(transition)) {
cache.set(transition, {});
}
const cacheEntry: InterpolationCacheEntry = cache.get(transition)!;
const startTime = transition.params.start_ms ?? 0;
let t = clamp((time - startTime) / transition.params.duration_ms, 0, 1);
if (transition.params.kind === 'transform_matrix') {
processTransformMatrix(transition, target, t, cacheEntry);
continue;
}
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const offset = transition.params.property[0] === 'custom' ? 1 : 0;
const startBase = transition.params.start ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition, startBase, transition.params.end as ColorT | undefined);
}
const paletteFn = cacheEntry.paletteFn!;
const startValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(0))
: startBase;
const endValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(1))
: transition.params.end;
if (time <= startTime) {
assign(target, transition.params.property, startValue, offset);
continue;
}
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
const startTime: number = transition.params.start_ms ?? 0;
const durationMs: number = transition.params.duration_ms ?? 0;
const t = (time - startTime) / durationMs;
let next: any;
if (transition.params.kind === 'scalar') {
next = interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0);
} else if (transition.params.kind === 'vec3') {
next = interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
next = interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
const color = paletteFn(t);
next = Color.toHexStyle(color);
if (transition.params.kind === 'transform_matrix') {
next = processTransformMatrix(transition, target, clamp(t, 0, 1), cacheEntry, offset, previous);
} else {
next = processScalarLike(transition, target, t, cacheEntry, offset, previous);
}
if (next === undefined) {
continue;
}
modified = true;
assign(target, transition.params.property, next, offset);
}
});
return modified ? ret : tree;
}
function applyFrequency(t: number, frequency: number, alternate: boolean) {
@@ -162,6 +197,53 @@ function applyFrequency(t: number, frequency: number, alternate: boolean) {
return v;
}
function getPreviousScalarEnd(previous: MVSAnimationNode<'interpolate'> | undefined) {
if (!previous || previous.params.kind === 'transform_matrix') return undefined;
return previous.params.end;
}
function processScalarLike(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cacheEntry: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind === 'transform_matrix') return;
if (previous && previous.params.kind === 'transform_matrix') return;
const startValue = transition.params.start ?? getPreviousScalarEnd(previous) ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition);
}
const endValue: any = transition.params.end;
if (time <= 0) return startValue;
else if (time >= 1 - EPSILON && !transition.params.alternate_direction && transition.params.kind !== 'color') return endValue;
let t = clamp(time, 0, 1);
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
if (transition.params.kind === 'scalar') {
return interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
return interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
return interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
if (cacheEntry.paletteFn) {
const color = cacheEntry.paletteFn(t);
return Color.toHexStyle(color);
}
const baseColors = typeof startValue === 'object' ? select(target, transition.params.property, offset) : undefined;
return interpolateColors(startValue, endValue, t, cacheEntry, baseColors);
}
}
function getPreviousMatrixEnd(previous: MVSAnimationNode<'interpolate'> | undefined, prop: 'rotation_start' | 'translation_start' | 'scale_start') {
if (!previous || previous.params.kind !== 'transform_matrix') return undefined;
return previous.params[prop];
}
const TransformState = {
pivotTranslation: Mat4(),
pivotTranslationInv: Mat4(),
@@ -171,31 +253,41 @@ const TransformState = {
pivotNeg: Vec3(),
temp: Mat4(),
};
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry) {
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind !== 'transform_matrix') return;
if (previous && previous.params.kind !== 'transform_matrix') return;
const offset = transition.params.property[0] === 'custom' ? 1 : 0;
const transform = select(target, transition.params.property, offset) ?? Mat4.identity();
const startRotation = transition.params.rotation_start ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? Mat4.getScaling(Vec3(), transform);
const startRotation = transition.params.rotation_start ?? getPreviousMatrixEnd(previous, 'rotation_start') ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? getPreviousMatrixEnd(previous, 'translation_start') ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? getPreviousMatrixEnd(previous, 'scale_start') ?? Mat4.getScaling(Vec3(), transform);
const endRotation = transition.params.rotation_end;
const endTranslation = transition.params.translation_end;
const endScale = transition.params.scale_end;
let t = applyFrequency(time, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
const rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
let rotation, translation, scale;
t = applyFrequency(time, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
const translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
if (time <= 0) {
rotation = startRotation as Mat3;
translation = startTranslation as Vec3;
scale = startScale as Vec3;
} else {
const clampedTime = clamp(time, 0, 1);
t = applyFrequency(time, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
const scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
let t = applyFrequency(clampedTime, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
t = applyFrequency(clampedTime, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
t = applyFrequency(clampedTime, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
}
const pivot = transition.params.pivot ?? Vec3.zero();
@@ -212,21 +304,21 @@ function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, tar
Mat4.mul(result, TransformState.rotation, result);
Mat4.mul(result, TransformState.translation, result);
assign(target, transition.params.property, result, offset);
return result;
}
function interpolateScalars(start: number | number[], end: number | number[] | undefined, t: number, noise: number) {
function interpolateScalars(start: number | number[], end: number | number[] | undefined, t: number, noise: number, discrete: boolean) {
if (Array.isArray(start)) {
const ret = Array.from<number>({ length: start.length }).fill(0.1);
if (!end || !Array.isArray(end)) {
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end, t, noise);
ret[i] = interpolateScalar(start[i], end, t, noise, discrete);
}
return ret;
}
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end[i], t, noise);
ret[i] = interpolateScalar(start[i], end[i], t, noise, discrete);
}
return ret;
}
@@ -234,19 +326,22 @@ function interpolateScalars(start: number | number[], end: number | number[] | u
if (Array.isArray(end)) {
const ret = Array.from<number>({ length: end.length }).fill(0.1);
for (let i = 0; i < end.length; i++) {
ret[i] = interpolateScalar(start, end[i], t, noise);
ret[i] = interpolateScalar(start, end[i], t, noise, discrete);
}
return ret;
}
return interpolateScalar(start, end, t, noise);
return interpolateScalar(start, end, t, noise, discrete);
}
function interpolateScalar(start: number, end: number | undefined, t: number, noise: number) {
function interpolateScalar(start: number, end: number | undefined, t: number, noise: number, discrete: boolean) {
let v = typeof end === 'number' ? lerp(start, end, t) : start;
if (noise) {
v += (Math.random() - 0.5) * noise;
}
if (discrete) {
v = Math.round(v);
}
return v;
}
@@ -347,6 +442,76 @@ function interpolateRotation(start: Mat3, end: Mat3 | undefined, t: number, nois
return Mat3.fromMat4(Mat3(), RotationState.temp);
}
function decodeColors(color: ColorT | Record<number | string, ColorT> | undefined, baseColors: Record<number | string, ColorT> | undefined) {
if (color === undefined || color === null) return undefined;
if (typeof color === 'object') {
const ret: Record<number | string, Color> = {};
if (baseColors) {
for (const key of Object.keys(baseColors)) {
const decoded = decodeColor(baseColors[key]);
if (decoded !== undefined) {
ret[key] = decoded;
}
}
}
for (const key of Object.keys(color)) {
const decoded = decodeColor(color[key]);
if (decoded !== undefined) {
ret[key] = decoded;
}
}
return ret;
}
return decodeColor(color);
}
function interpolateColors(start: ColorT | Record<number, ColorT>, end: ColorT | Record<number, ColorT> | undefined, time: number, cacheEntry: InterpolationCacheEntry, baseColors: Record<number, ColorT> | undefined) {
const t = clamp(time, 0, 1);
if (cacheEntry.paletteFn) {
const c = cacheEntry.paletteFn(t);
return Color.toHexStyle(c);
}
if (cacheEntry.startColor === undefined) {
cacheEntry.startColor = decodeColors(start, baseColors);
}
if (cacheEntry.endColor === undefined) {
cacheEntry.endColor = decodeColors(end, undefined);
}
const { startColor, endColor } = cacheEntry;
if (typeof startColor === 'object') {
if (typeof baseColors !== 'object') {
throw new Error('Cannot interpolate from scalar color to color mapping');
}
const ret = { ...baseColors as any, ...startColor as any };
if (typeof endColor === 'object') {
for (const key of Object.keys(endColor)) {
ret[key] = Color.toHexStyle(Color.interpolate(startColor[key], endColor[key], t));
}
} else if (typeof endColor === 'number') {
for (const key of Object.keys(startColor)) {
ret[key] = Color.toHexStyle(Color.interpolate(startColor[key], endColor, t));
}
}
return ret;
}
if (typeof endColor === 'object') {
throw new Error('Cannot interpolate from scalar color to color mapping');
}
if (typeof endColor === 'number' && typeof startColor === 'number') {
return Color.toHexStyle(Color.interpolate(startColor, endColor, t));
}
return start;
}
function select(params: any, path: string | (string | number)[], offset: number) {
if (typeof path === 'string') {
return params?.[path];
@@ -380,22 +545,29 @@ function assign(params: any, path: string | (string | number)[], value: any, off
}
}
function findNode(tree: Tree, ref: string): Tree | undefined {
if (tree.ref === ref) return tree;
if (!tree.children) return undefined;
for (const child of tree.children) {
const result = findNode(child, ref);
if (result) return result;
function makeNodeMap(tree: Tree, map: Map<string, (string | number)[]>, currentPath: (string | number)[]) {
if (tree.ref) {
map.set(tree.ref, [...currentPath]);
}
return undefined;
if (!tree.children) return map;
currentPath.push('children');
for (let i = 0; i < tree.children.length; i++) {
const child = tree.children[i];
currentPath.push(i);
makeNodeMap(child, map, currentPath);
currentPath.pop();
}
currentPath.pop();
return map;
}
function makePaletteFunction(props: MVSAnimationNode<'interpolate'>, start: ColorT | undefined | null, end: ColorT | undefined | null): ((value: number) => Color) | undefined {
if (props.params.kind !== 'color') return undefined;
function makePaletteFunction(props: MVSAnimationNode<'interpolate'>): ((value: number) => Color) | undefined {
if (props.params.kind !== 'color' || !props.params.palette) return undefined;
const params = props.params.palette
? palettePropsFromMVSPalette(props.params.palette)
: palettePropsFromMVSPalette({ kind: 'continuous', colors: [start ?? 'black', end ?? start ?? 'black'] });
const params = palettePropsFromMVSPalette(props.params.palette);
if (params.name === 'discrete') return makePaletteFunctionDiscrete(params.params);
if (params.name === 'continuous') return makePaletteFunctionContinuous(params.params);
throw new Error(`NotImplementedError: makePaletteFunction for ${(props as any).name}`);
@@ -435,7 +607,7 @@ function makePaletteFunctionContinuous(props: MVSContinuousPaletteProps): (value
return overflowColor;
}
const q = (x - checkpoints[gteIdx - 1]) / (checkpoints[gteIdx] - checkpoints[gteIdx - 1]);
return Color.interpolateHsl(colors[gteIdx - 1], colors[gteIdx], q);
return Color.interpolate(colors[gteIdx - 1], colors[gteIdx], q);
};
}

View File

@@ -152,4 +152,25 @@ export function collectMVSReferences<T extends StateObject.Ctor>(type: T[], depe
}
return ret;
}
export function getMVSReferenceObject<T extends StateObject.Ctor>(type: T[], dependencies: Record<string, StateObject> | undefined, ref: string): StateObject | undefined {
if (!dependencies) return undefined;
for (const key of Object.keys(dependencies)) {
const o = dependencies[key];
let okType = false;
for (const t of type) {
if (t.is(o)) {
okType = true;
break;
}
}
if (!okType || !o.tags) continue;
for (const tag of o.tags) {
if (tag.startsWith('mvs-ref:')) {
if (tag.substring(8) === ref) return o;
}
}
}
}

View File

@@ -369,10 +369,20 @@ function representationPropsBase(node: MolstarSubtree<'representation'>): Partia
type: { name: 'cartoon', params: { alpha, tubularHelices: params.tubular_helices } },
sizeTheme: { name: 'uniform', params: { value: params.size_factor } },
};
case 'backbone':
return {
type: { name: 'backbone', params: { alpha } },
sizeTheme: { name: 'uniform', params: { value: params.size_factor } },
};
case 'ball_and_stick':
return {
type: { name: 'ball-and-stick', params: { sizeFactor: (params.size_factor ?? 1) * 0.5, sizeAspectRatio: 0.5, alpha, ignoreHydrogens: params.ignore_hydrogens } },
};
case 'line':
return {
type: { name: 'line', params: { alpha, ignoreHydrogens: params.ignore_hydrogens } },
sizeTheme: { name: 'uniform', params: { value: params.size_factor } },
};
case 'spacefill':
return {
type: { name: 'spacefill', params: { alpha, ignoreHydrogens: params.ignore_hydrogens } },
@@ -402,8 +412,8 @@ export function representationProps(node: MolstarSubtree<'representation'>): Par
if (clip) {
base.type!.params = { ...base.type?.params, clip };
}
if (node.custom?.molstar_reprepresentation_params) {
base.type!.params = { ...base.type!.params, ...node.custom.molstar_reprepresentation_params };
if (node.custom?.molstar_representation_params) {
base.type!.params = { ...base.type!.params, ...node.custom.molstar_representation_params };
}
return base;
}

View File

@@ -8,8 +8,8 @@
import { PluginStateSnapshotManager } from '../../mol-plugin-state/manager/snapshots';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { Download, ParseCcp4, ParseCif } from '../../mol-plugin-state/transforms/data';
import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif, TrajectoryFromPDB } from '../../mol-plugin-state/transforms/model';
import { Download, ParseCif, ParseCcp4 } from '../../mol-plugin-state/transforms/data';
import { CoordinatesFromLammpstraj, CoordinatesFromXtc, CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromGRO, TrajectoryFromLammpsTrajData, TrajectoryFromMmCif, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../mol-plugin-state/transforms/model';
import { StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { VolumeFromCcp4, VolumeFromDensityServerCif } from '../../mol-plugin-state/transforms/volume';
import { PluginCommands } from '../../mol-plugin/commands';
@@ -26,16 +26,18 @@ import { CustomLabelProps, CustomLabelRepresentationProvider } from './component
import { CustomTooltipsProvider } from './components/custom-tooltips-prop';
import { IsMVSModelProps, IsMVSModelProvider } from './components/is-mvs-model-prop';
import { getPrimitiveStructureRefs, MVSBuildPrimitiveShape, MVSDownloadPrimitiveData, MVSInlinePrimitiveData, MVSShapeRepresentation3D } from './components/primitives';
import { MVSTrajectoryWithCoordinates } from './components/trajectory';
import { generateStateTransition } from './helpers/animation';
import { IsHiddenCustomStateExtension } from './load-extensions/is-hidden-custom-state';
import { NonCovalentInteractionsExtension } from './load-extensions/non-covalent-interactions';
import { LoadingActions, LoadingExtension, loadTreeVirtual, UpdateTarget } from './load-generic';
import { AnnotationFromSourceKind, AnnotationFromUriKind, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformAndInstantiateStructure, transformAndInstantiateVolume, volumeColorThemeForNode, volumeRepresentationProps } from './load-helpers';
import { MVSData, MVSData_States, Snapshot, SnapshotMetadata } from './mvs-data';
import { validateTree } from './tree/generic/tree-schema';
import { MVSAnimationNode, MVSAnimationSchema } from './tree/animation/animation-tree';
import { validateTree } from './tree/generic/tree-validation';
import { convertMvsToMolstar, mvsSanityCheck } from './tree/molstar/conversion';
import { MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree, MolstarTreeSchema } from './tree/molstar/molstar-tree';
import { type MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
export interface MVSLoadOptions {
@@ -49,6 +51,7 @@ export interface MVSLoadOptions {
sanityChecks?: boolean,
/** Base for resolving relative URLs/URIs. May itself be a relative URL (relative to the window URL). */
sourceUrl?: string,
doNotReportErrors?: boolean
}
@@ -64,6 +67,9 @@ async function _loadMVS(ctx: RuntimeContext, plugin: PluginContext, data: MVSDat
const mvsExtensionLoaded = plugin.state.hasBehavior(MolViewSpec);
if (!mvsExtensionLoaded) throw new Error('MolViewSpec extension is not loaded.');
// Stop any currently running audio
plugin.managers.markdownExtensions.audio.dispose();
// Reset canvas props to default so that modifyCanvasProps works as expected
resetCanvasProps(plugin);
@@ -73,18 +79,21 @@ async function _loadMVS(ctx: RuntimeContext, plugin: PluginContext, data: MVSDat
for (let i = 0; i < multiData.snapshots.length; i++) {
const snapshot = multiData.snapshots[i];
const previousSnapshot = i > 0 ? multiData.snapshots[i - 1] : multiData.snapshots[multiData.snapshots.length - 1];
validateTree(MVSTreeSchema, snapshot.root, 'MVS');
validateTree(MVSTreeSchema, snapshot.root, 'MVS', plugin);
if (snapshot.animation) {
validateTree(MVSAnimationSchema, snapshot.animation, 'Animation', plugin);
}
if (options.sanityChecks) mvsSanityCheck(snapshot.root);
const molstarTree = convertMvsToMolstar(snapshot.root, options.sourceUrl);
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar', plugin);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
snapshot.root,
snapshot.animation,
{ ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms },
options
);
await assignStateTransition(ctx, plugin, entry, snapshot, options);
await assignStateTransition(ctx, plugin, entry, snapshot, options, i, multiData.snapshots.length);
entries.push(entry);
if (ctx.shouldUpdate) {
@@ -119,8 +128,8 @@ async function _loadMVS(ctx: RuntimeContext, plugin: PluginContext, data: MVSDat
}
}
async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext, parentEntry: PluginStateSnapshotManager.Entry, parent: Snapshot, options: MVSLoadOptions = {}) {
const transitions = await generateStateTransition(ctx, parent);
async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext, parentEntry: PluginStateSnapshotManager.Entry, parent: Snapshot, options: MVSLoadOptions, snapshotIndex: number, snapshotCount: number) {
const transitions = await generateStateTransition(ctx, parent, snapshotIndex, snapshotCount);
if (!transitions?.frames.length) return;
const animation: PluginState.StateTransition = {
@@ -129,12 +138,13 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
frames: [],
};
for (const frame of transitions.frames) {
const molstarTree = convertMvsToMolstar(frame, options.sourceUrl);
for (let i = 0; i < transitions.frames.length; i++) {
const frame = transitions.frames[i];
const molstarTree = convertMvsToMolstar(frame[0], options.sourceUrl);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
frame,
parent.animation,
{ ...parent.metadata, previousTransitionDurationMs: transitions.frametimeMs },
options
);
@@ -142,14 +152,14 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
StateTree.reuseTransformParams(entry.snapshot.data!.tree, parentEntry.snapshot.data!.tree);
animation.frames.push({
durationInMs: transitions.frametimeMs,
durationInMs: frame[1],
data: entry.snapshot.data!,
camera: transitions.tree.params?.include_camera ? entry.snapshot.camera : undefined,
canvas3d: transitions.tree.params?.include_canvas ? entry.snapshot.canvas3d : undefined,
});
if (ctx.shouldUpdate) {
await ctx.update({ message: 'Generating transition...' });
await ctx.update({ message: `Loading animation for snapshot ${snapshotIndex + 1}/${snapshotCount}...`, current: i + 1, max: transitions.frames.length });
}
}
@@ -159,20 +169,24 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
function molstarTreeToEntry(
plugin: PluginContext,
tree: MolstarTree,
mvsTree: MVSTree,
animation: MVSAnimationNode<'animation'> | undefined,
metadata: SnapshotMetadata & { previousTransitionDurationMs?: number },
options: { keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }
) {
const context = MolstarLoadingContext.create();
const snapshot = loadTreeVirtual(plugin, tree, MolstarLoadingActions, context, { replaceExisting: true, extensions: options?.extensions ?? BuiltinLoadingExtensions });
snapshot.canvas3d = {
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas, mvsTree.custom) : undefined,
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas, animation) : undefined,
};
if (!options?.keepCamera) {
snapshot.camera = createPluginStateSnapshotCamera(plugin, context, metadata);
}
snapshot.durationInMs = metadata.linger_duration_ms + (metadata.previousTransitionDurationMs ?? 0);
if (tree.custom?.molstar_on_load_markdown_commands) {
snapshot.onLoadMarkdownCommands = tree.custom.molstar_on_load_markdown_commands;
}
const entryParams: PluginStateSnapshotManager.EntryParams = {
key: metadata.key,
name: metadata.title,
@@ -219,31 +233,72 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
},
parse(updateParent: UpdateTarget, node: MolstarNode<'parse'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, ParseCif, {});
} else if (format === 'pdb') {
return updateParent;
} else if (format === 'map') {
return UpdateTarget.apply(updateParent, ParseCcp4, {});
} else {
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, ParseCif, {});
case 'pdb':
case 'pdbqt':
case 'gro':
case 'xyz':
case 'mol':
case 'sdf':
case 'mol2':
case 'xtc':
case 'lammpstrj':
return updateParent;
case 'map':
return UpdateTarget.apply(updateParent, ParseCcp4, {});
default:
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
}
},
coordinates(updateParent: UpdateTarget, node: MolstarNode<'coordinates'>): UpdateTarget | undefined {
const format = node.params.format;
switch (format) {
case 'xtc':
return UpdateTarget.apply(updateParent, CoordinatesFromXtc);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, CoordinatesFromLammpstraj);
default:
console.error(`Unknown format in "coordinates" node: "${format}"`);
return undefined;
}
},
trajectory(updateParent: UpdateTarget, node: MolstarNode<'trajectory'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
} else if (format === 'pdb') {
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, {});
} else {
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
case 'pdb':
case 'pdbqt':
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, { isPdbqt: format === 'pdbqt' });
case 'gro':
return UpdateTarget.apply(updateParent, TrajectoryFromGRO);
case 'xyz':
return UpdateTarget.apply(updateParent, TrajectoryFromXYZ);
case 'mol':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL);
case 'sdf':
return UpdateTarget.apply(updateParent, TrajectoryFromSDF);
case 'mol2':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL2);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, TrajectoryFromLammpsTrajData);
default:
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
}
},
trajectory_with_coordinates(updateParent: UpdateTarget, node: MolstarNode<'trajectory_with_coordinates'>): UpdateTarget | undefined {
const result = UpdateTarget.apply(updateParent, MVSTrajectoryWithCoordinates, {
coordinatesRef: node.params.coordinates_ref,
});
return UpdateTarget.setMvsDependencies(result, [node.params.coordinates_ref]);
},
model(updateParent: UpdateTarget, node: MolstarSubtree<'model'>, context: MolstarLoadingContext): UpdateTarget {
const annotations = collectAnnotationReferences(node, context);
const model = UpdateTarget.apply(updateParent, ModelFromTrajectory, {

View File

@@ -5,7 +5,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { treeValidationIssues } from './tree/generic/tree-schema';
import { treeValidationIssues } from './tree/generic/tree-validation';
import { treeToString } from './tree/generic/tree-utils';
import { MVSAnimationSchema, MVSAnimationTree } from './tree/animation/animation-tree';
import { Root, createMVSBuilder } from './tree/mvs/mvs-builder';

View File

@@ -4,7 +4,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { bool, float, int, list, OptionalField, RequiredField, str, union, nullable, literal, ValueFor } from '../generic/field-schema';
import { bool, float, int, list, OptionalField, RequiredField, str, union, nullable, literal, ValueFor, dict } from '../generic/field-schema';
import { SimpleParamsSchema, UnionParamsSchema } from '../generic/params-schema';
import { NodeFor, ParamsOfKind, SubtreeOfKind, TreeFor, TreeSchema } from '../generic/tree-schema';
import { ColorT, ContinuousPalette, DiscretePalette, Matrix, Vector3 } from '../mvs/param-types';
@@ -48,6 +48,7 @@ const ScalarInterpolation = {
..._Easing,
start: OptionalField(nullable(union(float, list(float))), null, 'Start value. If a list of values is provided, each element will be interpolated separately. If unset, parent state value is used.'),
end: OptionalField(nullable(union(float, list(float))), null, 'End value. If a list of values is provided, each element will be interpolated separately. If unset, only noise is applied.'),
discrete: OptionalField(bool, false, 'Whether to round the values to the closest integer. Useful for example for trajectory animation.'),
..._Noise,
};
@@ -74,8 +75,8 @@ const ColorInterpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(ColorT), null, 'Start value. If unset, parent state value is used.'),
end: OptionalField(nullable(ColorT), null, 'End value.'),
start: OptionalField(union(nullable(ColorT), dict(union(int, str), ColorT)), null, 'Start value. If unset, parent state value is used.'),
end: OptionalField(union(nullable(ColorT), dict(union(int, str), ColorT)), null, 'End value.'),
palette: OptionalField(nullable(union(DiscretePalette, ContinuousPalette)), null, 'Palette to sample colors from. Overrides start and end values.'),
};

View File

@@ -4,12 +4,8 @@
* @author Adam Midlik <midlik@gmail.com>
*/
import { onelinerJsonString } from '../../../../mol-util/json';
import { isPlainObject, mapObjectMap } from '../../../../mol-util/object';
import { Field } from './field-schema';
import { AllRequired, ParamsSchema, SimpleParamsSchema, ValuesFor, paramsValidationIssues } from './params-schema';
import { treeToString } from './tree-utils';
import { mapObjectMap } from '../../../../mol-util/object';
import { AllRequired, ParamsSchema, ValuesFor } from './params-schema';
/** Type of "custom" of a tree node (key-value storage with arbitrary JSONable values) */
export type CustomProps = Partial<Record<string, any>>
@@ -114,120 +110,3 @@ export type NodeFor<TTreeSchema extends TreeSchema, TKind extends keyof ParamsSc
/** Type of tree which conforms to tree schema `TTreeSchema` */
export type TreeFor<TTreeSchema extends TreeSchema> = Tree<NodeFor<TTreeSchema>, RootFor<TTreeSchema> & NodeFor<TTreeSchema>>
/** Return `undefined` if a tree conforms to the given schema,
* return validation issues (as a list of lines) if it does not conform.
* If `options.requireAll`, all parameters (including optional) must have a value provided.
* If `options.noExtra` is true, presence of any extra parameters is treated as an issue.
* If `options.anyRoot` is true, the kind of the root node is not enforced.
*/
export function treeValidationIssues(schema: TreeSchema, tree: Tree, options: { requireAll?: boolean, noExtra?: boolean, anyRoot?: boolean, parent?: string } = {}): string[] | undefined {
if (!isPlainObject(tree)) return [`Node must be an object, not ${tree}`];
if (!options.anyRoot && tree.kind !== schema.rootKind) return [`Invalid root node kind "${tree.kind}", root must be of kind "${schema.rootKind}"`];
const nodeSchema = schema.nodes[tree.kind];
if (!nodeSchema) return [`Unknown node kind "${tree.kind}"`];
if (nodeSchema.parent && (options.parent !== undefined) && !nodeSchema.parent.includes(options.parent)) {
return [`Node of kind "${tree.kind}" cannot appear as a child of "${options.parent}". Allowed parents for "${tree.kind}" are: ${nodeSchema.parent.map(s => `"${s}"`).join(', ')}`];
}
const issues = paramsValidationIssues(nodeSchema.params, getParams(tree), options);
if (issues) return [`Invalid parameters for node of kind "${tree.kind}":`, ...issues.map(s => ' ' + s)];
if (tree.custom !== undefined && (typeof tree.custom !== 'object' || tree.custom === null)) {
return [`Invalid "custom" for node of kind "${tree.kind}": must be an object, not ${tree.custom}.`];
}
for (const child of getChildren(tree)) {
const issues = treeValidationIssues(schema, child, { ...options, anyRoot: true, parent: tree.kind });
if (issues) return issues;
}
return undefined;
}
/** Validate a tree against the given schema.
* Do nothing if OK; print validation issues on console and throw an error is the tree does not conform.
* Include `label` in the printed output. */
export function validateTree(schema: TreeSchema, tree: Tree, label: string): void {
const issues = treeValidationIssues(schema, tree, { noExtra: true });
if (issues) {
console.warn(`Invalid ${label} tree:\n${treeToString(tree)}`);
console.error(`${label} tree validation issues:`);
for (const line of issues) {
console.error(' ', line);
}
throw new Error('FormatError');
}
}
/** Return documentation for a tree schema as plain text */
export function treeSchemaToString<S extends TreeSchema>(schema: S): string {
return treeSchemaToString_(schema, false);
}
/** Return documentation for a tree schema as markdown text */
export function treeSchemaToMarkdown<S extends TreeSchema>(schema: S): string {
return treeSchemaToString_(schema, true);
}
function treeSchemaToString_<S extends TreeSchema>(schema: S, markdown: boolean = false): string {
const out: string[] = [];
const bold = (str: string) => markdown ? `**${str}**` : str;
const code = (str: string) => markdown ? `\`${str}\`` : str;
const h1 = markdown ? '## ' : ' - ';
const p1 = markdown ? '' : ' ';
const h2 = markdown ? '- ' : ' - ';
const p2 = markdown ? ' ' : ' ';
const h3 = markdown ? ' - ' : ' - ';
const p3 = markdown ? ' ' : ' ';
const newline = markdown ? '\n\n' : '\n';
out.push(`Tree schema:`);
for (const kind in schema.nodes) {
const { description, params, parent } = schema.nodes[kind];
out.push(`${h1}${code(kind)}`);
if (kind === schema.rootKind) {
out.push(`${p1}[Root of the tree must be of this kind]`);
}
if (description) {
out.push(`${p1}${description}`);
}
out.push(`${p1}Parent: ${!parent ? 'any' : parent.length === 0 ? 'none' : parent.map(code).join(' or ')}`);
out.push(`${p1}Params:${Object.keys(params).length > 0 ? '' : ' none'}`);
if (params.type === 'simple') {
formatSimpleParams(out, params, { h: h2, p: p2, code, bold });
} else {
const key = params.discriminator;
const casesStr = Object.keys(params.cases).join(' | ');
out.push(`${h2}${bold(code(key + ': '))}${code(casesStr)}`);
if (params.discriminatorDescription) {
out.push(`${p2}${params.discriminatorDescription}`);
}
out.push(`${p2}[This parameter determines the rest of parameters]`);
for (const case_ in params.cases) {
const caseStr = `${params.discriminator}: "${case_}"`;
out.push(`${p2}${bold(`Case ${code(caseStr)}:`)}`);
formatSimpleParams(out, params.cases[case_], { h: h3, p: p3, code, bold });
}
}
}
return out.join(newline);
}
function formatSimpleParams(out: string[], params: SimpleParamsSchema, formatting: { h: string, p: string, code: (str: string) => string, bold: (str: string) => string }): void {
const { h, p, code, bold } = formatting;
for (const key in params.fields) {
const field = params.fields[key];
out.push(`${h}${bold(code(key + (field.required ? ': ' : '?: ')))}${code(formatFieldType(field))}`);
const defaultValue = field.required ? undefined : field.default;
if (field.description) {
out.push(`${p}${field.description}`);
}
if (defaultValue !== undefined) {
out.push(`${p}Default: ${code(onelinerJsonString(defaultValue))}`);
}
}
}
function formatFieldType(field: Field): string {
const typeString = field.type.name;
if (typeString.startsWith('(') && typeString.endsWith(')')) {
return typeString.slice(1, -1);
} else {
return typeString;
}
}

View File

@@ -86,7 +86,7 @@ export function copyTree<T extends Tree>(root: T): T {
* nodes of kind `C` will be converted to `Y` with a child `Z` (original children moved to `Z`),
* nodes of other kinds will just be copied. */
export type ConversionRules<A extends Tree, B extends Tree> = {
[kind in Kind<Subtree<A>>]?: (node: SubtreeOfKind<A, kind>, parent?: Subtree<A>) => Subtree<B>[]
[kind in Kind<Subtree<A>>]?: (node: SubtreeOfKind<A, kind>, parent?: Subtree<A>) => { subtree: Subtree<B>[] }
};
/** Apply a set of conversion rules to a tree to change to a different schema. */
@@ -94,12 +94,12 @@ export function convertTree<A extends Tree, B extends Tree>(root: A, conversions
const mapping = new Map<Subtree<A>, Subtree<B>>();
let convertedRoot: Subtree<B>;
dfs<A>(root, (node, parent) => {
const conversion = conversions[node.kind as (typeof node)['kind']] as ((n: typeof node, p?: Subtree<A>) => Subtree<B>[]) | undefined;
const conversion = conversions[node.kind as (typeof node)['kind']] as ((n: typeof node, p?: Subtree<A>) => { subtree: Subtree<B>[] }) | undefined;
if (conversion) {
const convertidos = conversion(node, parent);
if (!parent && convertidos.length === 0) throw new Error('Cannot convert root to empty path');
const converted = conversion(node, parent);
if (!parent && converted?.subtree.length === 0) throw new Error('Cannot convert root to empty path');
let convParent = parent ? mapping.get(parent) : undefined;
for (const conv of convertidos) {
for (const conv of converted.subtree) {
if (convParent) {
(convParent.children ??= []).push(conv);
} else {
@@ -153,12 +153,14 @@ export function addDefaults<S extends TreeSchema>(tree: TreeFor<S>, treeSchema:
type TTree = TreeFor<S>;
const rules: ConversionRules<TTree, TTree> = {};
for (const kind in treeSchema.nodes) {
rules[kind as Kind<Subtree<TTree>>] = node => [{
kind: node.kind,
params: addParamDefaults(treeSchema.nodes[kind].params, node.params as any),
custom: node.custom,
ref: node.ref,
} as Node as any];
rules[kind as Kind<Subtree<TTree>>] = node => ({
subtree: [{
kind: node.kind,
params: addParamDefaults(treeSchema.nodes[kind].params, node.params as any),
custom: node.custom,
ref: node.ref,
} as Node as any]
});
}
return convertTree(tree, rules) as any;
}

View File

@@ -0,0 +1,125 @@
import { PluginContext } from '../../../../mol-plugin/context';
import { onelinerJsonString } from '../../../../mol-util/json';
import { isPlainObject } from '../../../../mol-util/object';
import { Field } from './field-schema';
import { SimpleParamsSchema, paramsValidationIssues } from './params-schema';
import { getChildren, getParams, Tree, TreeSchema } from './tree-schema';
import { treeToString } from './tree-utils';
/** Return `undefined` if a tree conforms to the given schema,
* return validation issues (as a list of lines) if it does not conform.
* If `options.requireAll`, all parameters (including optional) must have a value provided.
* If `options.noExtra` is true, presence of any extra parameters is treated as an issue.
* If `options.anyRoot` is true, the kind of the root node is not enforced.
*/
export function treeValidationIssues(schema: TreeSchema, tree: Tree, options: { requireAll?: boolean, noExtra?: boolean, anyRoot?: boolean, parent?: string } = {}): string[] | undefined {
if (!isPlainObject(tree)) return [`Node must be an object, not ${tree}`];
if (!options.anyRoot && tree.kind !== schema.rootKind) return [`Invalid root node kind "${tree.kind}", root must be of kind "${schema.rootKind}"`];
const nodeSchema = schema.nodes[tree.kind];
if (!nodeSchema) return [`Unknown node kind "${tree.kind}"`];
if (nodeSchema.parent && (options.parent !== undefined) && !nodeSchema.parent.includes(options.parent)) {
return [`Node of kind "${tree.kind}" cannot appear as a child of "${options.parent}". Allowed parents for "${tree.kind}" are: ${nodeSchema.parent.map(s => `"${s}"`).join(', ')}`];
}
const issues = paramsValidationIssues(nodeSchema.params, getParams(tree), options);
if (issues) return [`Invalid parameters for node of kind "${tree.kind}":`, ...issues.map(s => ' ' + s)];
if (tree.custom !== undefined && (typeof tree.custom !== 'object' || tree.custom === null)) {
return [`Invalid "custom" for node of kind "${tree.kind}": must be an object, not ${tree.custom}.`];
}
for (const child of getChildren(tree)) {
const issues = treeValidationIssues(schema, child, { ...options, anyRoot: true, parent: tree.kind });
if (issues) return issues;
}
return undefined;
}
/** Validate a tree against the given schema.
* Do nothing if OK; print validation issues on console and throw an error is the tree does not conform.
* Include `label` in the printed output. */
export function validateTree(schema: TreeSchema, tree: Tree, label: string, plugin: PluginContext): void {
const issues = treeValidationIssues(schema, tree, { noExtra: true });
if (issues) {
console.warn(`Invalid ${label} tree:\n${treeToString(tree)}`);
console.error(`${label} tree validation issues:`);
plugin.log.error(`${label} tree validation issues:`);
for (const line of issues) {
console.error(' ', line);
plugin.log.error(line);
}
throw new Error('FormatError');
}
}
/** Return documentation for a tree schema as plain text */
export function treeSchemaToString<S extends TreeSchema>(schema: S): string {
return treeSchemaToString_(schema, false);
}
/** Return documentation for a tree schema as markdown text */
export function treeSchemaToMarkdown<S extends TreeSchema>(schema: S): string {
return treeSchemaToString_(schema, true);
}
function treeSchemaToString_<S extends TreeSchema>(schema: S, markdown: boolean = false): string {
const out: string[] = [];
const bold = (str: string) => markdown ? `**${str}**` : str;
const code = (str: string) => markdown ? `\`${str}\`` : str;
const h1 = markdown ? '## ' : ' - ';
const p1 = markdown ? '' : ' ';
const h2 = markdown ? '- ' : ' - ';
const p2 = markdown ? ' ' : ' ';
const h3 = markdown ? ' - ' : ' - ';
const p3 = markdown ? ' ' : ' ';
const newline = markdown ? '\n\n' : '\n';
out.push(`Tree schema:`);
for (const kind in schema.nodes) {
const { description, params, parent } = schema.nodes[kind];
out.push(`${h1}${code(kind)}`);
if (kind === schema.rootKind) {
out.push(`${p1}[Root of the tree must be of this kind]`);
}
if (description) {
out.push(`${p1}${description}`);
}
out.push(`${p1}Parent: ${!parent ? 'any' : parent.length === 0 ? 'none' : parent.map(code).join(' or ')}`);
out.push(`${p1}Params:${Object.keys(params).length > 0 ? '' : ' none'}`);
if (params.type === 'simple') {
formatSimpleParams(out, params, { h: h2, p: p2, code, bold });
} else {
const key = params.discriminator;
const casesStr = Object.keys(params.cases).join(' | ');
out.push(`${h2}${bold(code(key + ': '))}${code(casesStr)}`);
if (params.discriminatorDescription) {
out.push(`${p2}${params.discriminatorDescription}`);
}
out.push(`${p2}[This parameter determines the rest of parameters]`);
for (const case_ in params.cases) {
const caseStr = `${params.discriminator}: "${case_}"`;
out.push(`${p2}${bold(`Case ${code(caseStr)}:`)}`);
formatSimpleParams(out, params.cases[case_], { h: h3, p: p3, code, bold });
}
}
}
return out.join(newline);
}
function formatSimpleParams(out: string[], params: SimpleParamsSchema, formatting: { h: string, p: string, code: (str: string) => string, bold: (str: string) => string }): void {
const { h, p, code, bold } = formatting;
for (const key in params.fields) {
const field = params.fields[key];
out.push(`${h}${bold(code(key + (field.required ? ': ' : '?: ')))}${code(formatFieldType(field))}`);
const defaultValue = field.required ? undefined : field.default;
if (field.description) {
out.push(`${p}${field.description}`);
}
if (defaultValue !== undefined) {
out.push(`${p}Default: ${code(onelinerJsonString(defaultValue))}`);
}
}
}
function formatFieldType(field: Field): string {
const typeString = field.type.name;
if (typeString.startsWith('(') && typeString.endsWith(')')) {
return typeString.slice(1, -1);
} else {
return typeString;
}
}

View File

@@ -15,37 +15,76 @@ import { MolstarKind, MolstarNode, MolstarTree } from './molstar-tree';
/** Convert `format` parameter of `parse` node in `MolstarTree`
* into `format` and `is_binary` parameters in `MolstarTree` */
export const ParseFormatMvsToMolstar = {
// trajectory
mmcif: { format: 'cif', is_binary: false },
bcif: { format: 'cif', is_binary: true },
pdb: { format: 'pdb', is_binary: false },
pdbqt: { format: 'pdbqt', is_binary: false },
gro: { format: 'gro', is_binary: false },
xyz: { format: 'xyz', is_binary: false },
mol: { format: 'mol', is_binary: false },
sdf: { format: 'sdf', is_binary: false },
mol2: { format: 'mol2', is_binary: false },
lammpstrj: { format: 'lammpstrj', is_binary: false },
// coordinates
xtc: { format: 'xtc', is_binary: true },
// maps
map: { format: 'map', is_binary: true },
} satisfies { [p in ParseFormatT]: { format: MolstarParseFormatT, is_binary: boolean } };
/** Conversion rules for conversion from `MVSTree` (with all parameter values) to `MolstarTree` */
const mvsToMolstarConversionRules: ConversionRules<FullMVSTree, MolstarTree> = {
'download': node => [],
'download': node => ({ subtree: [] }),
'parse': (node, parent) => {
const { format, is_binary } = ParseFormatMvsToMolstar[node.params.format];
const convertedNode: MolstarNode<'parse'> = { kind: 'parse', params: { ...node.params, format }, custom: node.custom, ref: node.ref };
if (parent?.kind === 'download') {
return [
{ kind: 'download', params: { ...parent.params, is_binary }, custom: parent.custom, ref: parent.ref },
convertedNode,
] satisfies MolstarNode[];
return {
subtree: [
{ kind: 'download', params: { ...parent.params, is_binary }, custom: parent.custom, ref: parent.ref },
{ kind: 'parse', params: { ...node.params, format }, custom: node.custom, ref: node.ref }
] satisfies MolstarNode[]
};
} else {
console.warn('"parse" node is not being converted, this is suspicious');
return [convertedNode] satisfies MolstarNode[];
return {
subtree: [
{ kind: 'parse', params: { ...node.params, format }, custom: node.custom, ref: node.ref }
] satisfies MolstarNode[]
};
}
},
'coordinates': (node, parent) => {
if (parent?.kind !== 'parse') throw new Error(`Parent of "coordinates" must be "parse", not "${parent?.kind}".`);
const { format } = ParseFormatMvsToMolstar[parent.params.format];
return {
subtree: [
{ kind: 'coordinates', params: { format }, custom: node.custom, ref: node.ref }
] satisfies MolstarNode[]
};
},
'structure': (node, parent) => {
if (parent?.kind !== 'parse') throw new Error(`Parent of "structure" must be "parse", not "${parent?.kind}".`);
const { format } = ParseFormatMvsToMolstar[parent.params.format];
return [
{ kind: 'trajectory', params: { format, ...pickObjectKeys(node.params, ['block_header', 'block_index']) } },
{ kind: 'model', params: pickObjectKeys(node.params, ['model_index']) },
{ kind: 'structure', params: omitObjectKeys(node.params, ['block_header', 'block_index', 'model_index']), custom: node.custom, ref: node.ref },
] satisfies MolstarNode[];
if (node.params.coordinates_ref) {
return {
subtree: [
{ kind: 'trajectory', params: { format, ...pickObjectKeys(node.params, ['block_header', 'block_index']) } },
{ kind: 'model', params: { model_index: 0 } },
{ kind: 'trajectory_with_coordinates', params: { coordinates_ref: node.params.coordinates_ref } },
{ kind: 'model', params: pickObjectKeys(node.params, ['model_index']) },
{ kind: 'structure', params: omitObjectKeys(node.params, ['block_header', 'block_index', 'model_index', 'coordinates_ref']), custom: node.custom, ref: node.ref },
] satisfies MolstarNode[]
};
} else {
return {
subtree: [
{ kind: 'trajectory', params: { format, ...pickObjectKeys(node.params, ['block_header', 'block_index']) } },
{ kind: 'model', params: pickObjectKeys(node.params, ['model_index']) },
{ kind: 'structure', params: omitObjectKeys(node.params, ['block_header', 'block_index', 'model_index', 'coordinates_ref']), custom: node.custom, ref: node.ref },
] satisfies MolstarNode[]
};
}
},
};
@@ -70,9 +109,20 @@ function fileExtensionMatches(filename: string, extensions: (FileExtension | '*'
}
const StructureFormatExtensions: Record<ParseFormatT, (FileExtension | '*')[]> = {
// trajectory
mmcif: ['.cif', '.mmif'],
bcif: ['.bcif'],
pdb: ['.pdb', '.ent'],
pdbqt: ['.pdbqt'],
gro: ['.gro'],
xyz: ['.xyz'],
mol: ['.mol'],
sdf: ['.sdf'],
mol2: ['.mol2'],
lammpstrj: ['.lammpstrj'],
// coordinates
xtc: ['.xtc'],
// volumes
map: ['.map', '.ccp4', '.mrc', '.mrcs'],
};

View File

@@ -5,7 +5,7 @@
*/
import { omitObjectKeys, pickObjectKeys } from '../../../../mol-util/object';
import { RequiredField, bool } from '../generic/field-schema';
import { RequiredField, bool, str } from '../generic/field-schema';
import { SimpleParamsSchema } from '../generic/params-schema';
import { NodeFor, ParamsOfKind, SubtreeOfKind, TreeFor, TreeSchema } from '../generic/tree-schema';
import { FullMVSTreeSchema } from '../mvs/mvs-tree';
@@ -30,6 +30,14 @@ export const MolstarTreeSchema = TreeSchema({
format: RequiredField(MolstarParseFormatT, 'File format'),
}),
},
/** Auxiliary node corresponding to Molstar's CoordinatesFrom*. */
coordinates: {
description: "Auxiliary node corresponding to Molstar's CoordinatesFrom*.",
parent: ['parse'],
params: SimpleParamsSchema({
format: RequiredField(MolstarParseFormatT, 'File format'),
}),
},
/** Auxiliary node corresponding to Molstar's TrajectoryFrom*. */
trajectory: {
description: "Auxiliary node corresponding to Molstar's TrajectoryFrom*.",
@@ -39,10 +47,18 @@ export const MolstarTreeSchema = TreeSchema({
...pickObjectKeys(FullMVSTreeSchema.nodes.structure.params.fields, ['block_header', 'block_index'] as const),
}),
},
/** Auxiliary node corresponding to Molstar's TrajectoryFrom*. */
trajectory_with_coordinates: {
description: 'Auxiliary node corresponding to assigning a separate coordinates to a trajectory.',
parent: ['model'],
params: SimpleParamsSchema({
coordinates_ref: RequiredField(str, 'Coordinates reference'),
}),
},
/** Auxiliary node corresponding to Molstar's ModelFromTrajectory. */
model: {
description: "Auxiliary node corresponding to Molstar's ModelFromTrajectory.",
parent: ['trajectory'],
parent: ['trajectory', 'trajectory_with_coordinates'],
params: SimpleParamsSchema(
pickObjectKeys(FullMVSTreeSchema.nodes.structure.params.fields, ['model_index'] as const)
),
@@ -52,7 +68,7 @@ export const MolstarTreeSchema = TreeSchema({
...FullMVSTreeSchema.nodes.structure,
parent: ['model'],
params: SimpleParamsSchema(
omitObjectKeys(FullMVSTreeSchema.nodes.structure.params.fields, ['block_header', 'block_index', 'model_index'] as const)
omitObjectKeys(FullMVSTreeSchema.nodes.structure.params.fields, ['block_header', 'block_index', 'model_index', 'coordinates_ref'] as const)
),
},
}

View File

@@ -98,6 +98,12 @@ export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
this._animation ??= new Animation(params);
return this._animation;
}
/** Modifies custom state of the root */
extendRootCustomState(custom: Record<string, any>): this {
this._node.custom = { ...this._node.custom, ...custom };
return this;
}
}
export class Animation {
@@ -138,10 +144,10 @@ export class Download extends _Base<'download'> {
/** Subsets of 'structure' node params which will be passed to individual builder functions. */
const StructureParamsSubsets = {
model: ['block_header', 'block_index', 'model_index'],
assembly: ['block_header', 'block_index', 'model_index', 'assembly_id'],
symmetry: ['block_header', 'block_index', 'model_index', 'ijk_min', 'ijk_max'],
symmetry_mates: ['block_header', 'block_index', 'model_index', 'radius'],
model: ['block_header', 'block_index', 'model_index', 'coordinates_ref'],
assembly: ['block_header', 'block_index', 'model_index', 'assembly_id', 'coordinates_ref'],
symmetry: ['block_header', 'block_index', 'model_index', 'ijk_min', 'ijk_max', 'coordinates_ref'],
symmetry_mates: ['block_header', 'block_index', 'model_index', 'radius', 'coordinates_ref'],
} satisfies { [kind in MVSNodeParams<'structure'>['type']]: (keyof MVSNodeParams<'structure'>)[] };
@@ -191,6 +197,11 @@ export class Parse extends _Base<'parse'> {
volume(params: MVSNodeParams<'volume'> & CustomAndRef = {}): Volume {
return new Volume(this._root, this.addChild('volume', params));
}
/** Add a 'coordinates' node indicating the parsed data type */
coordinates(params: MVSNodeParams<'coordinates'> & CustomAndRef = {}): Parse {
this.addChild('coordinates', params);
return this;
}
}

View File

@@ -53,7 +53,7 @@ const LinesParams = {
vertices: RequiredField(FloatList, '3*n_vertices length array of floats with vertex position (x1, y1, z1, ...).'),
/** 2*n_lines length array of indices into vertices that form lines (l1_1, l1_2, ...). */
indices: RequiredField(IntList, '2*n_lines length array of indices into vertices that form lines (l1_1, l1_2, ...).'),
/** Assign a number to each triangle to group them. If not specified, each line is considered a separate group (line i = group i). */
/** Assign a number to each line to group them. If not specified, each line is considered a separate group (line i = group i). */
line_groups: OptionalField(nullable(IntList), null, 'Assign a number to each triangle to group them. If not specified, each line is considered a separate group (line i = group i).'),
/** Assign a color to each group. Where not assigned, uses `color`. */
group_colors: OptionalField(dict(int, ColorT), {}, 'Assign a color to each group. Where not assigned, uses `color`.'),

View File

@@ -15,6 +15,11 @@ const Cartoon = {
tubular_helices: OptionalField(bool, false, 'Simplify corkscrew helices to tubes.'),
};
const Backbone = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
};
const BallAndStick = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
@@ -22,6 +27,13 @@ const BallAndStick = {
ignore_hydrogens: OptionalField(bool, false, 'Controls whether hydrogen atoms are drawn.'),
};
const Line = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
/** Controls whether hydrogen atoms are drawn. */
ignore_hydrogens: OptionalField(bool, false, 'Controls whether hydrogen atoms are drawn.'),
};
const Spacefill = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
@@ -48,7 +60,9 @@ export const MVSRepresentationParams = UnionParamsSchema(
'Representation type',
{
cartoon: SimpleParamsSchema(Cartoon),
backbone: SimpleParamsSchema(Backbone),
ball_and_stick: SimpleParamsSchema(BallAndStick),
line: SimpleParamsSchema(Line),
spacefill: SimpleParamsSchema(Spacefill),
carbohydrate: SimpleParamsSchema(Carbohydrate),
surface: SimpleParamsSchema(Surface),

View File

@@ -92,6 +92,12 @@ export const MVSTreeSchema = TreeSchema({
format: RequiredField(ParseFormatT, 'Format of the input data resource.'),
}),
},
/** This node instructs to retrieve molecular coordinates from a parsed data resource. */
coordinates: {
description: 'This node instructs to retrieve molecular coordinates from a parsed data resource.',
parent: ['parse'],
params: SimpleParamsSchema({}),
},
/** This node instructs to create a structure from a parsed data resource. "Structure" refers to an internal representation of molecular coordinates without any visual representation. */
structure: {
description: 'This node instructs to create a structure from a parsed data resource. "Structure" refers to an internal representation of molecular coordinates without any visual representation.',
@@ -113,6 +119,8 @@ export const MVSTreeSchema = TreeSchema({
ijk_min: OptionalField(tuple([int, int, int]), [-1, -1, -1], 'Miller indices of the bottom-left unit cell to be included (only applies when `kind` is `"symmetry"`).'),
/** Miller indices of the top-right unit cell to be included (only applies when `kind` is `"symmetry"`). */
ijk_max: OptionalField(tuple([int, int, int]), [1, 1, 1], 'Miller indices of the top-right unit cell to be included (only applies when `kind` is `"symmetry"`).'),
/** Reference to a specific set of coordinates. */
coordinates_ref: OptionalField(nullable(str), null, 'Reference to a specific set of coordinates.')
}),
},
/** This node instructs to rotate and/or translate structure coordinates. */

View File

@@ -11,11 +11,42 @@ import { ValueFor, bool, dict, float, int, list, literal, nullable, object, part
/** `format` parameter values for `parse` node in MVS tree */
export const ParseFormatT = literal('mmcif', 'bcif', 'pdb', 'map');
export const ParseFormatT = literal(
// trajectory
'mmcif',
'bcif', // +volumes
'pdb',
'pdbqt',
'gro',
'xyz',
'mol',
'sdf',
'mol2',
'lammpstrj', // + coordinates
// coordinates
'xtc',
// volumes
'map',
);
export type ParseFormatT = ValueFor<typeof ParseFormatT>
/** `format` parameter values for `parse` node in Molstar tree */
export const MolstarParseFormatT = literal('cif', 'pdb', 'map');
export const MolstarParseFormatT = literal(
// trajectory
'cif', // +volumes
'pdb',
'pdbqt',
'gro',
'xyz',
'mol',
'sdf',
'mol2',
'lammpstrj',
// coordinates
'xtc',
// volumes
'map'
);
export type MolstarParseFormatT = ValueFor<typeof MolstarParseFormatT>
/** `kind` parameter values for `structure` node in MVS tree */

View File

@@ -13,7 +13,7 @@ import { Vec3, Vec2 } from '../mol-math/linear-algebra';
import { InputObserver, ModifiersKeys, ButtonsType } from '../mol-util/input/input-observer';
import { Renderer, RendererStats, RendererParams } from '../mol-gl/renderer';
import { GraphicsRenderObject } from '../mol-gl/render-object';
import { TrackballControls, TrackballControlsParams } from './controls/trackball';
import { DefaultTrackballControlsAttribs, TrackballControls, TrackballControlsParams } from './controls/trackball';
import { Viewport } from './camera/util';
import { createContext, WebGLContext, getGLContext } from '../mol-gl/webgl/context';
import { Representation } from '../mol-repr/representation';
@@ -34,7 +34,6 @@ import { ImagePass, ImageProps } from './passes/image';
import { Sphere3D } from '../mol-math/geometry';
import { addConsoleStatsProvider, isDebugMode, isTimingMode, removeConsoleStatsProvider } from '../mol-util/debug';
import { CameraHelperParams } from './helper/camera-helper';
import { produce } from 'immer';
import { HandleHelperParams } from './helper/handle-helper';
import { StereoCamera, StereoCameraParams } from './camera/stereo';
import { Helper } from './helper/helper';
@@ -49,6 +48,7 @@ import { IlluminationParams } from './passes/illumination';
import { isMobileBrowser } from '../mol-util/browser';
import { Ray3D } from '../mol-math/geometry/primitives/ray3d';
import { RayHelper } from './helper/ray-helper';
import { produce } from '../mol-util/produce';
export const CameraFogParams = {
intensity: PD.Numeric(15, { min: 1, max: 100, step: 1 }),
@@ -114,6 +114,11 @@ export type PartialCanvas3DProps = {
[K in keyof Canvas3DProps]?: Canvas3DProps[K] extends { name: string, params: any } ? Canvas3DProps[K] : Partial<Canvas3DProps[K]>
}
export const DefaultCanvas3DAttribs = {
trackball: DefaultTrackballControlsAttribs,
};
export type Canvas3DAttribs = typeof DefaultCanvas3DAttribs
export { Canvas3DContext };
/** Can be used to create multiple Canvas3D objects */
@@ -360,6 +365,7 @@ interface Canvas3D {
/** Returns a copy of the current Canvas3D instance props */
readonly props: Readonly<Canvas3DProps>
readonly attribs: Readonly<Canvas3DAttribs>
readonly input: InputObserver
readonly stats: RendererStats
readonly interaction: Canvas3dInteractionHelper['events']
@@ -379,9 +385,10 @@ 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(ctx: Canvas3DContext, props: Partial<Canvas3DProps> = {}): Canvas3D {
export function create(ctx: Canvas3DContext, props: Partial<Canvas3DProps> = {}, attribs: Partial<Canvas3DAttribs> = {}): Canvas3D {
const { webgl, input, passes, assetManager, canvas, contextLost } = ctx;
const p: Canvas3DProps = { ...deepClone(DefaultCanvas3DParams), ...deepClone(props) };
const a = { ...deepClone(DefaultCanvas3DAttribs), ...deepClone(attribs) };
const reprRenderObjects = new Map<Representation.Any, Set<GraphicsRenderObject>>();
const reprUpdatedSubscriptions = new Map<Representation.Any, Subscription>();
@@ -421,7 +428,7 @@ namespace Canvas3D {
}, { x, y, width, height });
const stereoCamera = new StereoCamera(camera, p.camera.stereo.params);
const controls = TrackballControls.create(input, camera, scene, p.trackball);
const controls = TrackballControls.create(input, camera, scene, p.trackball, a.trackball);
const helper = new Helper(webgl, scene, p);
const hiZ = new HiZPass(webgl, passes.draw, canvas, p.hiZ);
@@ -1180,6 +1187,9 @@ namespace Canvas3D {
get props() {
return getProps();
},
get attribs() {
return a;
},
get input() {
return input;
},

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
@@ -56,8 +56,6 @@ export const DefaultTrackballBindings = {
};
export const TrackballControlsParams = {
noScroll: PD.Boolean(true, { isHidden: true }),
rotateSpeed: PD.Numeric(5.0, { min: 1, max: 10, step: 1 }),
zoomSpeed: PD.Numeric(7.0, { min: 1, max: 15, step: 1 }),
panSpeed: PD.Numeric(1.0, { min: 0.1, max: 5, step: 0.1 }),
@@ -68,10 +66,10 @@ export const TrackballControlsParams = {
animate: PD.MappedStatic('off', {
off: PD.EmptyGroup(),
spin: PD.Group({
speed: PD.Numeric(1, { min: -20, max: 20, step: 1 }, { description: 'Rotation speed in radians per second' }),
speed: PD.Numeric(0.3, { min: -5, max: 5, step: 0.1 }, { description: 'Number of rotations per second' }),
}, { description: 'Spin the 3D scene around the x-axis in view space' }),
rock: PD.Group({
speed: PD.Numeric(0.3, { min: -5, max: 5, step: 0.1 }),
speed: PD.Numeric(0.3, { min: -5, max: 5, step: 0.1 }, { description: 'Number of oscilations per second' }),
angle: PD.Numeric(10, { min: 0, max: 90, step: 1 }, { description: 'How many degrees to rotate in each direction.' }),
}, { description: 'Rock the 3D scene around the x-axis in view space' })
}),
@@ -85,8 +83,6 @@ export const TrackballControlsParams = {
gestureScaleFactor: PD.Numeric(1, {}, { isHidden: true }),
maxWheelDelta: PD.Numeric(0.02, {}, { isHidden: true }),
bindings: PD.Value(DefaultTrackballBindings, { isHidden: true }),
/**
* minDistance = minDistanceFactor * boundingSphere.radius + minDistancePadding
* maxDistance = max(maxDistanceFactor * boundingSphere.radius, maxDistanceMin)
@@ -103,6 +99,11 @@ export const TrackballControlsParams = {
};
export type TrackballControlsProps = PD.Values<typeof TrackballControlsParams>
export const DefaultTrackballControlsAttribs = {
bindings: DefaultTrackballBindings,
};
export type TrackballControlsAttribs = typeof DefaultTrackballControlsAttribs
export { TrackballControls };
interface TrackballControls {
readonly viewport: Viewport
@@ -112,20 +113,25 @@ interface TrackballControls {
readonly props: Readonly<TrackballControlsProps>
setProps: (props: Partial<TrackballControlsProps>) => void
readonly attribs: Readonly<TrackballControlsAttribs>
setAttribs: (attribs: Partial<TrackballControlsAttribs>) => void
start: (t: number) => void
update: (t: number) => void
reset: () => void
dispose: () => void
}
namespace TrackballControls {
export function create(input: InputObserver, camera: Camera, scene: Scene, props: Partial<TrackballControlsProps> = {}): TrackballControls {
export function create(input: InputObserver, camera: Camera, scene: Scene, props: Partial<TrackballControlsProps> = {}, attribs: Partial<TrackballControlsAttribs> = {}): TrackballControls {
const p: TrackballControlsProps = {
...PD.getDefaultValues(TrackballControlsParams),
...props,
// include default bindings for backwards state compatibility
bindings: { ...DefaultTrackballBindings, ...props.bindings }
};
const b = p.bindings;
const a: TrackballControlsAttribs = {
...DefaultTrackballControlsAttribs,
...attribs
};
const b = a.bindings;
const viewport = Viewport.clone(camera.viewport);
@@ -825,7 +831,7 @@ namespace TrackballControls {
function spin(deltaT: number) {
if (p.animate.name !== 'spin' || p.animate.params.speed === 0 || _isInteracting) return;
const radPerMs = p.animate.params.speed / 1000;
const radPerMs = 2 * Math.PI * p.animate.params.speed / 1000;
_spinSpeed[0] = deltaT * radPerMs / getRotateFactor();
Vec2.add(_rotCurr, _rotPrev, _spinSpeed);
}
@@ -885,7 +891,12 @@ namespace TrackballControls {
}
}
Object.assign(p, props);
Object.assign(b, props.bindings);
},
get attribs() { return a as Readonly<TrackballControlsAttribs>; },
setAttribs: (attribs: Partial<TrackballControlsAttribs>) => {
Object.assign(a, attribs);
Object.assign(b, a.bindings);
},
start,

View File

@@ -4,7 +4,7 @@
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { produce } from 'immer';
import { produce } from '../../mol-util/produce';
import { Interval } from '../../mol-data/int/interval';
import { addCylinder } from '../../mol-geo/geometry/mesh/builder/cylinder';
import { addSphere } from '../../mol-geo/geometry/mesh/builder/sphere';

View File

@@ -16,7 +16,7 @@ import { addCylinder } from '../../mol-geo/geometry/mesh/builder/cylinder';
import { ValueCell } from '../../mol-util';
import { Sphere3D } from '../../mol-math/geometry';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { produce } from 'immer';
import { produce } from '../../mol-util/produce';
import { Shape } from '../../mol-model/shape';
import { PickingId } from '../../mol-geo/geometry/picking';
import { Camera } from '../camera';

View File

@@ -445,8 +445,9 @@ function areImageTexturePropsEqual(sourceA: ImageProps['source'], sourceB: Image
function getImageTexture(ctx: WebGLContext, assetManager: AssetManager, source: ImageProps['source'], onload?: (errored?: boolean) => void): { texture: Texture, asset: Asset } {
const asset = source.name === 'url'
? Asset.getUrlAsset(assetManager, source.params)
? assetManager.tryFindFilename(source.params) ?? Asset.getUrlAsset(assetManager, source.params)
: source.params!;
if (typeof HTMLImageElement === 'undefined') {
console.error(`Missing "HTMLImageElement" required for background image`);
onload?.(true);

View File

@@ -167,6 +167,7 @@ export class DpoitPass {
if (isTimingMode) this.webgl.timer.mark('DpoitPass.render');
const { state, gl } = this.webgl;
state.blendEquation(gl.FUNC_ADD);
state.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
ValueCell.update(this.renderable.values.tDpoitFrontColor, this.colorFrontTextures[this.writeId]);

View File

@@ -198,8 +198,10 @@ export class DrawPass {
const dpoitTextures = this.dpoit.bindDualDepthPeeling();
renderer.renderDpoitTransparent(scene.primitives, camera, this.depthTextureOpaque, dpoitTextures);
target.bind();
this.dpoit.renderBlendBack();
if (iterations > 1) {
target.bind();
this.dpoit.renderBlendBack();
}
if (isTimingMode) this.webgl.timer.markEnd('DpoitPass.layer');
}

View File

@@ -171,13 +171,15 @@ export class IlluminationPass {
const dpoitTextures = this.drawPass.dpoit.bind();
renderer.renderDpoitTransparent(scene.primitives, camera, this.drawPass.depthTextureOpaque, dpoitTextures);
for (let i = 0, il = props.dpoitIterations; i < il; i++) {
for (let i = 0, iterations = props.dpoitIterations; i < iterations; i++) {
if (isTimingMode) this.webgl.timer.mark('DpoitPass.layer');
const dpoitTextures = this.drawPass.dpoit.bindDualDepthPeeling();
renderer.renderDpoitTransparent(scene.primitives, camera, this.drawPass.depthTextureOpaque, dpoitTextures);
this.transparentTarget.bind();
this.drawPass.dpoit.renderBlendBack();
if (iterations > 1) {
this.transparentTarget.bind();
this.drawPass.dpoit.renderBlendBack();
}
if (isTimingMode) this.webgl.timer.markEnd('DpoitPass.layer');
}

View File

@@ -3,6 +3,7 @@
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Adam Midlik <midlik@gmail.com>
*/
// from http://burtleburtle.net/bob/hash/integer.html
@@ -237,6 +238,8 @@ function multiplyBy256BitPrime(hash: Uint32Array): void {
// hash * prime = hash * (2^88 + 2^8 + 0x3b) = (hash << 88) + (hash << 8) + hash * 0x3b
// hash << 88 (shift left by 88 bits = 2 full 32-bit words + 24 bits)
MultTmp1[0] = 0;
MultTmp1[1] = 0;
MultTmp1[2] = hash[0] << 24;
MultTmp1[3] = (hash[0] >>> 8) | (hash[1] << 24);
MultTmp1[4] = (hash[1] >>> 8) | (hash[2] << 24);
@@ -282,6 +285,7 @@ const _8digit_padding = [
'0'
];
function padHexNumber(num: number): string {
const base = num.toString(16);
if (base.length >= 8) return base; // No padding needed
@@ -297,4 +301,245 @@ function hashFnv256aToHex(hash: Uint32Array): string {
result += padHexNumber(hash[i]);
}
return result;
}
/**
* 32-bit Murmur hash
*/
export function hashMurmur32o(obj: any, seed: number = 42): number {
const jsonString = JSON.stringify(obj);
return murmurHash3_32(jsonString, seed);
}
/**
* 128-bit Murmur hash
*/
export function hashMurmur128o(obj: any, seed: number = 42): string {
const jsonString = JSON.stringify(obj);
return murmurHash3_128(jsonString, seed);
}
/**
* MurmurHash3 32-bit implementation
* @param key - The input string to hash
* @param seed - The seed value (default: 0)
* @returns The 32-bit hash as a number
*/
export function murmurHash3_32(key: string, seed: number): number {
let h = seed >>> 0;
const remainder = key.length % 4;
const bytes = key.length - remainder;
for (let i = 0; i < bytes; i += 4) {
let k = (key.charCodeAt(i) & 0xff) |
((key.charCodeAt(i + 1) & 0xff) << 8) |
((key.charCodeAt(i + 2) & 0xff) << 16) |
((key.charCodeAt(i + 3) & 0xff) << 24);
k = Math.imul(k, 0xcc9e2d51);
k = (k << 15) | (k >>> 17);
k = Math.imul(k, 0x1b873593);
h ^= k;
h = (h << 13) | (h >>> 19);
h = Math.imul(h, 5) + 0xe6546b64;
}
let k = 0;
switch (remainder) {
case 3: k ^= (key.charCodeAt(bytes + 2) & 0xff) << 16;
case 2: k ^= (key.charCodeAt(bytes + 1) & 0xff) << 8;
case 1: k ^= (key.charCodeAt(bytes) & 0xff);
k = Math.imul(k, 0xcc9e2d51);
k = (k << 15) | (k >>> 17);
k = Math.imul(k, 0x1b873593);
h ^= k;
}
h ^= key.length;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
/**
* MurmurHash3 128-bit implementation
* @param key - The input data to hash
* @param seed - The seed value (default: 0)
* @returns The 128-bit hash as a hexadecimal string
*/
export function murmurHash3_128_fromBytes(key: Uint8Array, seed: number): string {
// This fakeString approach is much faster than `new TextDecoder('ascii').decode(key)`
const fakeString = {
length: key.length,
charCodeAt(i: number) { return key[i]; },
};
return murmurHash3_128(fakeString as string, seed);
}
/**
* MurmurHash3 128-bit implementation
* @param key - The input string to hash
* @param seed - The seed value (default: 0)
* @returns The 128-bit hash as a hexadecimal string
*/
export function murmurHash3_128(key: string, seed: number): string {
let h1 = seed >>> 0;
let h2 = seed >>> 0;
let h3 = seed >>> 0;
let h4 = seed >>> 0;
const remainder = key.length % 16;
const bytes = key.length - remainder;
for (let i = 0; i < bytes; i += 16) {
let k1 = (key.charCodeAt(i) & 0xff) |
((key.charCodeAt(i + 1) & 0xff) << 8) |
((key.charCodeAt(i + 2) & 0xff) << 16) |
((key.charCodeAt(i + 3) & 0xff) << 24);
let k2 = (key.charCodeAt(i + 4) & 0xff) |
((key.charCodeAt(i + 5) & 0xff) << 8) |
((key.charCodeAt(i + 6) & 0xff) << 16) |
((key.charCodeAt(i + 7) & 0xff) << 24);
let k3 = (key.charCodeAt(i + 8) & 0xff) |
((key.charCodeAt(i + 9) & 0xff) << 8) |
((key.charCodeAt(i + 10) & 0xff) << 16) |
((key.charCodeAt(i + 11) & 0xff) << 24);
let k4 = (key.charCodeAt(i + 12) & 0xff) |
((key.charCodeAt(i + 13) & 0xff) << 8) |
((key.charCodeAt(i + 14) & 0xff) << 16) |
((key.charCodeAt(i + 15) & 0xff) << 24);
k1 = Math.imul(k1, 0x239b961b);
k1 = (k1 << 15) | (k1 >>> 17);
k1 = Math.imul(k1, 0xab0e9789);
h1 ^= k1;
h1 = (h1 << 19) | (h1 >>> 13);
h1 += h2;
h1 = Math.imul(h1, 5) + 0x561ccd1b;
k2 = Math.imul(k2, 0xab0e9789);
k2 = (k2 << 16) | (k2 >>> 16);
k2 = Math.imul(k2, 0x38b34ae5);
h2 ^= k2;
h2 = (h2 << 17) | (h2 >>> 15);
h2 += h3;
h2 = Math.imul(h2, 5) + 0x0bcaa747;
k3 = Math.imul(k3, 0x38b34ae5);
k3 = (k3 << 17) | (k3 >>> 15);
k3 = Math.imul(k3, 0xa1e38b93);
h3 ^= k3;
h3 = (h3 << 15) | (h3 >>> 17);
h3 += h4;
h3 = Math.imul(h3, 5) + 0x96cd1c35;
k4 = Math.imul(k4, 0xa1e38b93);
k4 = (k4 << 13) | (k4 >>> 19);
k4 = Math.imul(k4, 0x239b961b);
h4 ^= k4;
h4 = (h4 << 13) | (h4 >>> 19);
h4 += h1;
h4 = Math.imul(h4, 5) + 0x32ac3b17;
}
let k1 = 0, k2 = 0, k3 = 0, k4 = 0;
switch (remainder) {
case 15: k4 ^= key.charCodeAt(bytes + 14) << 16;
case 14: k4 ^= key.charCodeAt(bytes + 13) << 8;
case 13: k4 ^= key.charCodeAt(bytes + 12);
k4 = Math.imul(k4, 0xa1e38b93);
k4 = (k4 << 13) | (k4 >>> 19);
k4 = Math.imul(k4, 0x239b961b);
h4 ^= k4;
case 12: k3 ^= key.charCodeAt(bytes + 11) << 24;
case 11: k3 ^= key.charCodeAt(bytes + 10) << 16;
case 10: k3 ^= key.charCodeAt(bytes + 9) << 8;
case 9: k3 ^= key.charCodeAt(bytes + 8);
k3 = Math.imul(k3, 0x38b34ae5);
k3 = (k3 << 17) | (k3 >>> 15);
k3 = Math.imul(k3, 0xa1e38b93);
h3 ^= k3;
case 8: k2 ^= key.charCodeAt(bytes + 7) << 24;
case 7: k2 ^= key.charCodeAt(bytes + 6) << 16;
case 6: k2 ^= key.charCodeAt(bytes + 5) << 8;
case 5: k2 ^= key.charCodeAt(bytes + 4);
k2 = Math.imul(k2, 0xab0e9789);
k2 = (k2 << 16) | (k2 >>> 16);
k2 = Math.imul(k2, 0x38b34ae5);
h2 ^= k2;
case 4: k1 ^= key.charCodeAt(bytes + 3) << 24;
case 3: k1 ^= key.charCodeAt(bytes + 2) << 16;
case 2: k1 ^= key.charCodeAt(bytes + 1) << 8;
case 1: k1 ^= key.charCodeAt(bytes);
k1 = Math.imul(k1, 0x239b961b);
k1 = (k1 << 15) | (k1 >>> 17);
k1 = Math.imul(k1, 0xab0e9789);
h1 ^= k1;
}
h1 ^= key.length;
h2 ^= key.length;
h3 ^= key.length;
h4 ^= key.length;
h1 += h2;
h1 += h3;
h1 += h4;
h2 += h1;
h3 += h1;
h4 += h1;
h1 ^= h1 >>> 16;
h1 = Math.imul(h1, 0x85ebca6b);
h1 ^= h1 >>> 13;
h1 = Math.imul(h1, 0xc2b2ae35);
h1 ^= h1 >>> 16;
h2 ^= h2 >>> 16;
h2 = Math.imul(h2, 0x85ebca6b);
h2 ^= h2 >>> 13;
h2 = Math.imul(h2, 0xc2b2ae35);
h2 ^= h2 >>> 16;
h3 ^= h3 >>> 16;
h3 = Math.imul(h3, 0x85ebca6b);
h3 ^= h3 >>> 13;
h3 = Math.imul(h3, 0xc2b2ae35);
h3 ^= h3 >>> 16;
h4 ^= h4 >>> 16;
h4 = Math.imul(h4, 0x85ebca6b);
h4 ^= h4 >>> 13;
h4 = Math.imul(h4, 0xc2b2ae35);
h4 ^= h4 >>> 16;
h1 += h2;
h1 += h3;
h1 += h4;
h2 += h1;
h3 += h1;
h4 += h1;
return (
(h1 >>> 0).toString(16).padStart(8, '0') +
(h2 >>> 0).toString(16).padStart(8, '0') +
(h3 >>> 0).toString(16).padStart(8, '0') +
(h4 >>> 0).toString(16).padStart(8, '0')
);
}

View File

@@ -483,13 +483,13 @@ namespace Renderer {
const alpha = clamp(r.values.alpha.ref.value * r.state.alphaFactor, 0, 1);
const xrayShaded = r.values.dXrayShaded?.ref.value === 'on' || r.values.dXrayShaded?.ref.value === 'inverted';
return (
(alpha < 1 && alpha !== 0) ||
alpha !== 0 && (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.dGeometryType.ref.value === 'image' ||
xrayShaded
xrayShaded)
);
};

View File

@@ -38,11 +38,11 @@ float getDepthOpaque(const in vec2 coords) {
#endif
}
float getDepthTransparent(const in vec2 coords) {
vec2 getDepthTransparentWithAlpha(const in vec2 coords) {
#ifdef dTransparentOutline
return unpackRGBAToDepthWithAlpha(texture2D(tDepthTransparent, coords)).x;
return unpackRGBAToDepthWithAlpha(texture2D(tDepthTransparent, coords));
#else
return 1.0;
return vec2(1.0, 1.0);
#endif
}
@@ -66,7 +66,7 @@ void main(void) {
float selfViewZOpaque = isBackground(selfDepthOpaque) ? backgroundViewZ : getViewZ(selfDepthOpaque);
float pixelSizeOpaque = getPixelSize(coords, selfDepthOpaque) * uOutlineThreshold;
float selfDepthTransparent = getDepthTransparent(coords);
float selfDepthTransparent = getDepthTransparentWithAlpha(coords).x;
float selfViewZTransparent = isBackground(selfDepthTransparent) ? backgroundViewZ : getViewZ(selfDepthTransparent);
float pixelSizeTransparent = getPixelSize(coords, selfDepthTransparent) * uOutlineThreshold;
@@ -79,12 +79,15 @@ void main(void) {
vec2 sampleCoords = coords + vec2(float(x), float(y)) * invTexSize;
float sampleDepthOpaque = getDepthOpaque(sampleCoords);
float sampleDepthTransparent = getDepthTransparent(sampleCoords);
vec2 sampleDepthTransparentWithAlpha = getDepthTransparentWithAlpha(sampleCoords);
float sampleDepthTransparent = sampleDepthTransparentWithAlpha.x;
float sampleAlphaTransparent = sampleDepthTransparentWithAlpha.y;
float sampleViewZOpaque = isBackground(sampleDepthOpaque) ? backgroundViewZ : getViewZ(sampleDepthOpaque);
if (abs(selfViewZOpaque - sampleViewZOpaque) > pixelSizeOpaque && selfDepthOpaque > sampleDepthOpaque && sampleDepthOpaque <= bestDepth) {
outline = 0.0;
bestDepth = sampleDepthOpaque;
transparentFlag = 0.0;
}
if (sampleDepthTransparent < sampleDepthOpaque) {
@@ -92,7 +95,7 @@ void main(void) {
if (abs(selfViewZTransparent - sampleViewZTransparent) > pixelSizeTransparent && selfDepthTransparent > sampleDepthTransparent && sampleDepthTransparent <= bestDepth) {
outline = 0.0;
bestDepth = sampleDepthTransparent;
transparentFlag = 1.0;
transparentFlag = sampleAlphaTransparent;
}
}
}

View File

@@ -168,15 +168,25 @@ void main(void) {
if (outline == 0.0) {
float viewDist = abs(getViewZ(closestTexel));
float fogFactor = smoothstep(uFogNear, uFogFar, viewDist);
if (!uTransparentBackground) {
color.rgb = mix(uOutlineColor, uFogColor, fogFactor);
} else {
color.a = 1.0 - fogFactor;
color.rgb = mix(uOutlineColor, vec3(0.0), fogFactor);
}
#ifdef dBlendTransparency
if (isTransparentOutline == 1.0 || transparentDepth > closestTexel) {
blendTransparency = false;
if (isTransparentOutline > 0.0) {
float outlineAlpha = clamp(isTransparentOutline * 2.0, 0.0, 1.0);
transparentColor.a = transparentColor.a + outlineAlpha * (1.0 - fogFactor) * (1.0 - transparentColor.a);
transparentColor.rgb = uOutlineColor;
} else {
if (!uTransparentBackground) {
color.rgb = mix(uOutlineColor, uFogColor, fogFactor);
} else {
color.a = 1.0 - fogFactor;
color.rgb = mix(uOutlineColor, vec3(0.0), fogFactor);
}
}
#else
if (!uTransparentBackground) {
color.rgb = mix(uOutlineColor, uFogColor, fogFactor);
} else {
color.a = 1.0 - fogFactor;
color.rgb = mix(uOutlineColor, vec3(0.0), fogFactor);
}
#endif
}

View File

@@ -15,7 +15,8 @@ export { MinimizeRmsd };
namespace MinimizeRmsd {
export interface Result {
bTransform: Mat4,
rmsd: number
rmsd: number,
nAlignedElements: number,
}
export interface Positions { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> }
@@ -33,7 +34,7 @@ namespace MinimizeRmsd {
}
export function compute(data: Input, result?: MinimizeRmsd.Result) {
if (typeof result === 'undefined') result = { bTransform: Mat4.zero(), rmsd: 0.0 };
result ??= { bTransform: Mat4.zero(), rmsd: 0.0, nAlignedElements: 0 };
findMinimalRmsdTransformImpl(new RmsdTransformState(data, result));
return result;
}
@@ -170,4 +171,5 @@ function findMinimalRmsdTransformImpl(state: RmsdTransformState): void {
rmsd = rmsd < 0.0 ? 0.0 : Math.sqrt(rmsd / state.a.x.length);
makeTransformMatrix(state);
state.result.rmsd = rmsd;
state.result.nAlignedElements = state.a.x.length;
}

View File

@@ -16,7 +16,7 @@ export function volumeFromDensityServerData(source: DensityServer_Data_Database,
return Task.create<Volume>('Create Volume', async ctx => {
const { volume_data_3d_info: info, volume_data_3d: values } = source;
const cell = SpacegroupCell.create(
info.spacegroup_number.value(0),
info.spacegroup_number.value(0) || 'P 1',
Vec3.ofArray(info.spacegroup_cell_size.value(0)),
Vec3.scale(Vec3.zero(), Vec3.ofArray(info.spacegroup_cell_angles.value(0)), Math.PI / 180)
);

View File

@@ -18,7 +18,7 @@ export function volumeFromSegmentationData(source: Segmentation_Data_Database, p
return Task.create<Volume>('Create Segmentation Volume', async ctx => {
const { volume_data_3d_info: info, segmentation_data_3d: values } = source;
const cell = SpacegroupCell.create(
info.spacegroup_number.value(0),
info.spacegroup_number.value(0) || 'P 1',
Vec3.ofArray(info.spacegroup_cell_size.value(0)),
Vec3.scale(Vec3(), Vec3.ofArray(info.spacegroup_cell_angles.value(0)), Math.PI / 180)
);

View File

@@ -13,9 +13,11 @@ async function setPartialSnapshot(plugin: PluginContext, entry: Partial<PluginSt
if (entry.data) {
await plugin.runTask(plugin.state.data.setSnapshot(entry.data));
// update the canvas3d trackball with the snapshot
plugin.canvas3d?.setProps({
trackball: entry.canvas3d?.props?.trackball
});
if (entry.canvas3d?.props?.trackball) {
plugin.canvas3d?.setProps({
trackball: entry.canvas3d?.props?.trackball
});
}
}
@@ -175,7 +177,7 @@ export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
if (t.current >= animState.totalDuration) {
if (snapshot?.transition && animState.isInitial) {
const frameIndex = snapshot.transition.frames.length - 1;
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(frameIndex, false);
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(animState.totalDuration, false);
await setPartialSnapshot(ctx.plugin, snapshot.transition.frames[frameIndex]);
}
return { kind: 'finished' };
@@ -193,7 +195,7 @@ export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
return { kind: 'skip' };
}
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(frameIndex, false);
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(t.current, false);
if (frameIndex === 0) {
await setPartialSnapshot(ctx.plugin, {
...transition.frames[frameIndex],

View File

@@ -13,7 +13,7 @@ import { Loci } from '../../../mol-model/loci';
import { Structure } from '../../../mol-model/structure';
import { PluginContext } from '../../../mol-plugin/context';
import { PluginState } from '../../../mol-plugin/state';
import { StateObject, StateTransform } from '../../../mol-state';
import { StateObjectCell, StateSelection, StateTransform } from '../../../mol-state';
import { PluginStateObject } from '../../objects';
@@ -65,7 +65,7 @@ export function getCellBoundingSphere(plugin: PluginContext, cellRef: StateTrans
/** Push bounding spheres within cell `cellRef` to `out`. If a cell does not define bounding spheres, collect bounding spheres from subtree. */
function collectCellBoundingSpheres(out: Sphere3D[], plugin: PluginContext, cellRef: StateTransform.Ref): Sphere3D[] {
const cell = plugin.state.data.cells.get(cellRef);
const spheres = getStateObjectBoundingSpheres(cell?.obj);
const spheres = getStateObjectBoundingSpheres(plugin, cell);
if (spheres) {
out.push(...spheres);
} else {
@@ -76,14 +76,17 @@ function collectCellBoundingSpheres(out: Sphere3D[], plugin: PluginContext, cell
}
/** Return a set of bounding spheres of a plugin state object. Return `undefined` if this plugin state object type does not define bounding spheres. */
function getStateObjectBoundingSpheres(obj: StateObject | undefined): Sphere3D[] | undefined {
function getStateObjectBoundingSpheres(plugin: PluginContext, cell: StateObjectCell | undefined): Sphere3D[] | undefined {
const obj = cell?.obj;
if (!obj) return undefined;
if (!obj.data) {
console.warn('Focus: no data');
return undefined;
}
if (obj.data instanceof Structure) {
const sphere = Loci.getBoundingSphere(Structure.Loci(obj.data));
const decorated = StateSelection.getDecorated<PluginStateObject.Molecule.Structure>(plugin.state.data, cell.transform.ref);
const data = decorated?.obj?.data ?? obj?.data;
const sphere = Loci.getBoundingSphere(Structure.Loci(data));
return sphere ? [sphere] : [];
} else if (PluginStateObject.isRepresentation3D(obj)) {
const out: Sphere3D[] = [];

View File

@@ -6,10 +6,12 @@
import { getCellBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { StateObjectCell } from '../../mol-state';
import { StateObjectCell, StateSelection } from '../../mol-state';
import { PluginContext } from '../../mol-plugin/context';
import { Script } from '../../mol-script/script';
import { QueryContext, QueryFn, StructureElement, StructureSelection } from '../../mol-model/structure';
import { BehaviorSubject } from 'rxjs';
import { AnimateStateSnapshotTransition } from '../animation/built-in/state-snapshots';
export type MarkdownExtensionEvent = 'click' | 'mouse-enter' | 'mouse-leave';
@@ -45,6 +47,17 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
manager.plugin.managers.snapshot.applyKey(key);
}
},
{
name: 'next-snapshot',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('next-snapshot' in args)) return;
let dir: -1 | 1 = (+args['next-snapshot'] || 1) as -1 | 1;
if (!dir) return;
if (dir < 0) dir = -1;
else dir = 1;
manager.plugin.managers.snapshot.applyNext(dir);
}
},
{
name: 'focus-refs',
execute: ({ event, args, manager }) => {
@@ -135,7 +148,8 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
if (!action.includes('focus')) {
return;
}
const spheres = structures.map(s => {
const decorated = structures.map(s => StateSelection.getDecorated<PluginStateObject.Molecule.Structure>(manager.plugin.state.data, s.transform.ref));
const spheres = decorated.map(s => {
if (!s.obj?.data) return undefined;
const selection = query(new QueryContext(s.obj.data));
if (StructureSelection.isEmpty(selection)) return;
@@ -150,9 +164,74 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
}
},
},
{
name: 'play-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click') return;
const src = args['play-audio'];
if (!src?.length) return;
manager.audio.play(src);
}
},
{
name: 'toggle-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('toggle-audio' in args)) return;
const src = args['toggle-audio'];
manager.audio.play(src, { toggle: true });
}
},
{
name: 'pause-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('pause-audio' in args)) return;
manager.audio.pause();
}
},
{
name: 'stop-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('stop-audio' in args)) return;
manager.audio.stop();
}
},
{
name: 'dispose-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('dispose-audio' in args)) return;
manager.audio.dispose();
}
},
{
name: 'play-transition',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('play-transition' in args)) return;
manager.plugin.managers.animation.play(AnimateStateSnapshotTransition, {});
}
},
{
name: 'play-snapshots',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('play-snapshots' in args)) return;
manager.plugin.managers.snapshot.play({ restart: true });
}
},
{
name: 'stop-animation',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('stop-animation' in args)) return;
manager.plugin.managers.snapshot.stop();
}
},
];
export class MarkdownExtensionManager {
state = {
audioPlayer: new BehaviorSubject<HTMLAudioElement | null>(null),
};
private extension: MarkdownExtension[] = [];
private refResolvers: Record<string, (plugin: PluginContext, refs: string[]) => StateObjectCell[]> = {
default: (plugin: PluginContext, refs: string[]) => refs
@@ -286,6 +365,76 @@ export class MarkdownExtensionManager {
return ret;
}
private resolveAudioPlayer() {
if (this.state.audioPlayer.value) {
return this.state.audioPlayer.value;
}
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
audio.style.width = '100%';
audio.style.height = '32px';
this.state.audioPlayer.next(audio);
return audio;
}
get audioPlayer() {
return this.state.audioPlayer.value;
}
audio = {
play: async (src: string, options?: { toggle?: boolean }) => {
try {
const audio = this.resolveAudioPlayer();
let newSource = false;
if (src?.trim()) {
const resolved = this.tryResolveUri(src);
let uri: string = src;
if (typeof (resolved as Promise<string>)?.then === 'function') {
uri = (await resolved) as string;
} else if (resolved) {
uri = resolved as string;
}
newSource = audio.src !== uri;
if (newSource) {
audio.src = uri;
audio.load();
}
}
if (!newSource && options?.toggle) {
if (audio.paused) {
await audio.play();
} else {
audio.pause();
}
} else {
audio.currentTime = 0;
await audio.play();
}
} catch (e) {
console.error('Failed to play audio', e);
}
},
pause: () => {
this.audioPlayer?.pause();
},
stop: () => {
if (!this.audioPlayer) return;
this.audioPlayer.pause();
this.audioPlayer.currentTime = 0;
},
dispose: () => {
if (this.audioPlayer) {
this.audioPlayer.pause();
this.audioPlayer.currentTime = 0;
this.state.audioPlayer.next(null);
}
}
};
constructor(public plugin: PluginContext) {
for (const command of BuiltInMarkdownExtension) {
this.registerExtension(command);

View File

@@ -25,7 +25,7 @@ export { PluginStateSnapshotManager };
interface StateManagerState {
current?: UUID,
currentAnimationFrame?: number,
currentAnimationTimeMs?: number,
entries: List<PluginStateSnapshotManager.Entry>,
isPlaying: boolean,
nextSnapshotDelayInMs: number
@@ -38,8 +38,8 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
private defaultSnapshotId: UUID | undefined = undefined;
protected updateState(state: Partial<StateManagerState>) {
if ('current' in state && !('curentAnimationFrame' in state)) {
return super.updateState({ ...state, currentAnimationFrame: 0 });
if ('current' in state && !('currentAnimationTimeMs' in state)) {
return super.updateState({ ...state, currentAnimationTimeMs: 0 });
} else {
return super.updateState(state);
}
@@ -168,18 +168,23 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
private animationFrameQueue = new SingleTaskQueue();
setSnapshotAnimationFrame(frame: number, load = false) {
if (this.updateState({ currentAnimationFrame: frame })) {
setSnapshotAnimationFrame(currentAnimationTimeMs: number, load = false) {
const entry = this.getEntry(this.state.current);
if (!entry) return;
const frameIndex = PluginState.getStateTransitionFrameIndex(entry.snapshot, currentAnimationTimeMs) ?? 0;
if (this.updateState({ currentAnimationTimeMs })) {
this.events.changed.next(void 0);
}
if (load) {
this.animationFrameQueue.run(() => {
const entry = this.getEntry(this.state.current);
if (!entry) return Promise.resolve();
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frame);
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frameIndex ?? 0);
});
}
return frameIndex;
}
getNextId(id: string | undefined, dir: -1 | 1) {
@@ -233,7 +238,7 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
const next = entry && entry.snapshot;
if (!next) return;
await this.plugin.state.setSnapshot(next);
if (snapshot.playback && snapshot.playback.isPlaying) this.play(true);
if (snapshot.playback?.isPlaying) this.play({ delayFirst: true });
return next;
}
@@ -365,10 +370,33 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
if (this.state.isPlaying) this.timeoutHandle = setTimeout(this.next, delay);
};
play(delayFirst: boolean = false) {
private async startPlayback() {
const { current } = this;
if (!current) return;
// If there is a transition associated with the current snapshot, replay it
if (current.snapshot.transition) {
const snapshot = this.setCurrent(this.state.current!)!;
await this.plugin.state.setSnapshot(snapshot);
const delay = typeof snapshot.durationInMs !== 'undefined' ? snapshot.durationInMs : this.state.nextSnapshotDelayInMs;
if (this.state.isPlaying) this.timeoutHandle = setTimeout(this.next, delay);
} else {
return this.next();
}
}
async play(options?: { delayFirst?: boolean, restart?: boolean }) {
if (this.state.isPlaying && !options?.delayFirst) {
if (options?.restart) {
await this.stop();
} else {
return;
}
}
this.updateState({ isPlaying: true });
if (delayFirst) {
if (options?.delayFirst) {
const e = this.getEntry(this.state.current);
if (!e) {
this.next();
@@ -379,23 +407,24 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
const delay = typeof snapshot.durationInMs !== 'undefined' ? snapshot.durationInMs : this.state.nextSnapshotDelayInMs;
this.timeoutHandle = setTimeout(this.next, delay);
} else {
this.next();
this.startPlayback();
}
}
stop() {
async stop() {
await this.plugin.managers.animation.stop();
this.updateState({ isPlaying: false });
if (typeof this.timeoutHandle !== 'undefined') clearTimeout(this.timeoutHandle);
this.timeoutHandle = void 0;
this.events.changed.next(void 0);
}
togglePlay() {
async togglePlay() {
if (this.state.isPlaying) {
this.stop();
this.plugin.managers.animation.stop();
await this.stop();
this.plugin.managers.markdownExtensions.audio.pause();
} else {
this.play();
await this.play();
}
}

View File

@@ -225,7 +225,7 @@ const TopologyFromTop = PluginStateTransform.BuiltIn({
}
});
async function getTrajectory(ctx: RuntimeContext, obj: StateObject, coordinates: Coordinates) {
export async function getTrajectory(ctx: RuntimeContext, obj: StateObject, coordinates: Coordinates) {
if (obj.type === SO.Molecule.Topology.type) {
const topology = obj.data as Topology;
return await Model.trajectoryFromTopologyAndCoordinates(topology, coordinates).runInContext(ctx);
@@ -578,7 +578,7 @@ const ModelFromTrajectory = PluginStateTransform.BuiltIn({
isApplicable: a => a.data.frameCount > 0,
apply({ a, params }) {
return Task.create('Model from Trajectory', async ctx => {
let modelIndex = params.modelIndex % a.data.frameCount;
let modelIndex = Math.round(params.modelIndex) % a.data.frameCount;
if (modelIndex < 0) modelIndex += a.data.frameCount;
const model = await Task.resolveInContext(a.data.getFrameAtIndex(modelIndex), ctx);
const label = `Model ${modelIndex + 1}`;

View File

@@ -11,6 +11,7 @@ import { StateTransformParameters } from './state/common';
export class PluginUIContext extends PluginContext {
readonly customParamEditors = new Map<string, StateTransformParameters.Class>();
readonly customUIState: Record<string, any> = {};
private initCustomParamEditors() {
if (!this.spec.customParamEditors) return;

View File

@@ -29,6 +29,7 @@ import { StructureQuickStylesControls } from './structure/quick-styles';
import { Markdown } from './controls/markdown';
import { Slider } from './controls/slider';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { PluginState } from '../mol-plugin/state';
export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: boolean, label: string }> {
state = { show: false, label: '' };
@@ -61,7 +62,7 @@ export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: bo
count++;
if (!label) {
const idx = (m.transform.params! as StateTransformer.Params<ModelFromTrajectory>).modelIndex;
label = `Model ${idx + 1} / ${parent.data.frameCount}`;
label = `Model ${Math.round(idx + 1)} / ${parent.data.frameCount}`;
}
}
}
@@ -111,37 +112,12 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
this.subscribe(this.plugin.managers.snapshot.events.changed, () => this.forceUpdate());
this.subscribe(this.plugin.behaviors.state.isBusy, isBusy => this.setState({ isBusy }));
this.subscribe(this.plugin.behaviors.state.isAnimating, isBusy => this.setState({ isBusy }));
window.addEventListener('keyup', this.keyUp, false);
}
componentWillUnmount() {
super.componentWillUnmount();
window.removeEventListener('keyup', this.keyUp, false);
}
keyUp = (e: KeyboardEvent) => {
if (!e.ctrlKey || this.state.isBusy || e.target !== document.body) return;
const snapshots = this.plugin.managers.snapshot;
if (e.keyCode === 37 || e.key === 'ArrowLeft') {
if (snapshots.state.isPlaying) snapshots.stop();
this.prev();
} else if (e.keyCode === 38 || e.key === 'ArrowUp') {
if (snapshots.state.isPlaying) snapshots.stop();
if (snapshots.state.entries.size === 0) return;
const e = snapshots.state.entries.get(0)!;
this.update(e.snapshot.id);
} else if (e.keyCode === 39 || e.key === 'ArrowRight') {
if (snapshots.state.isPlaying) snapshots.stop();
this.next();
} else if (e.keyCode === 40 || e.key === 'ArrowDown') {
if (snapshots.state.isPlaying) snapshots.stop();
if (snapshots.state.entries.size === 0) return;
const e = snapshots.state.entries.get(snapshots.state.entries.size - 1)!;
this.update(e.snapshot.id);
}
};
async update(id: string) {
this.setState({ isBusy: true });
await PluginCommands.State.Snapshots.Apply(this.plugin, { id });
@@ -176,6 +152,7 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
toggleStateAnimation = () => {
if (this.state.isBusy) {
this.plugin.managers.animation.stop();
this.plugin.managers.markdownExtensions.audio.pause();
} else {
this.plugin.managers.animation.play(AnimateStateSnapshotTransition, {});
}
@@ -210,19 +187,17 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
{!isPlaying && <>
{count > 1 && <IconButton svg={NavigateBeforeSvg} title='Previous State' onClick={this.prev} disabled={disabled} />}
{count > 1 && <IconButton svg={NavigateNextSvg} title='Next State' onClick={this.next} disabled={disabled} />}
{hasAnimation && <IconButton svg={AnimationSvg} className='msp-state-snapshot-animation-button' title='Animation' onClick={this.toggleShowAnimation} disabled={!hasAnimation} toggleState={this.state.showAnimation} />}
{hasAnimation && <IconButton svg={AnimationSvg} className='msp-state-snapshot-animation-button' title='Snapshot Transition' onClick={this.toggleShowAnimation} disabled={!hasAnimation} toggleState={this.state.showAnimation} />}
</>}
{hasAnimation && this.state.showAnimation && !isPlaying && <>
<div className='msp-state-snapshot-animation-slider msp-form-control'>
<Slider
value={snapshots.state.currentAnimationFrame ?? 0}
min={1}
step={1}
max={(entry?.snapshot.transition?.frames.length ?? 1)}
value={Math.round(100 * (snapshots.state.currentAnimationTimeMs ?? 0)) /100}
min={0}
step={PluginState.getMinFrameDuration(entry?.snapshot)}
max={PluginState.getStateTransitionDuration(entry?.snapshot) ?? 1000}
onChange={() => { }}
onChangeImmediate={v => {
snapshots.setSnapshotAnimationFrame(v - 1, true);
}}
onChangeImmediate={v => snapshots.setSnapshotAnimationFrame(v, true)}
hideInput
disabled={this.state.isBusy}
/>

View File

@@ -4,7 +4,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useRef, useState } from 'react';
import ReactMarkdown, { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { PluginReactContext } from '../base';
@@ -13,9 +13,11 @@ import { PluginContext } from '../../mol-plugin/context';
import { MarkdownExtension } from '../../mol-plugin-state/manager/markdown-extensions';
import { ColorLists } from '../../mol-util/color/lists';
import { getColorGradient, getColorGradientBanded, parseColorList } from '../../mol-util/color/utils';
import { useBehavior } from '../hooks/use-behavior';
export function Markdown({ children, components }: { children?: string, components?: Components }) {
return <div className='msp-markdown'>
<MarkdownAudioPlayer />
<ReactMarkdown
skipHtml
components={{ a: MarkdownAnchor, img: MarkdownImg, ...components }}
@@ -26,6 +28,21 @@ export function Markdown({ children, components }: { children?: string, componen
</div>;
}
export function MarkdownAudioPlayer() {
const parent = useRef<HTMLDivElement>(null);
const plugin: PluginUIContext | undefined = useContext(PluginReactContext);
const audio = useBehavior(plugin?.managers.markdownExtensions.state.audioPlayer);
useEffect(() => {
if (!parent.current) return;
parent.current.appendChild(audio!);
return () => { audio?.remove(); };
}, [audio]);
if (!audio) return null;
return <div className='msp-markdown-audio-player' ref={parent} />;
}
export function MarkdownImg({ src, element, alt }: { src?: string, element?: any, alt?: string }) {
const plugin: PluginUIContext | undefined = useContext(PluginReactContext);

View File

@@ -47,7 +47,7 @@ export class ChainSequenceWrapper extends SequenceWrapper<StructureUnit> {
return Interval.Empty;
}
getLoci(seqIdx: number) {
override getLoci(seqIdx: number) {
return this.loci;
}

View File

@@ -50,7 +50,7 @@ export class ElementSequenceWrapper extends SequenceWrapper<StructureUnit> {
return Interval.Empty;
}
getLoci(seqIdx: number) {
override getLoci(seqIdx: number) {
const { units } = this.data;
const lociElements: StructureElement.Loci['elements'][0][] = [];
let offset = 0;

View File

@@ -53,7 +53,7 @@ export class HeteroSequenceWrapper extends SequenceWrapper<StructureUnit> {
return Interval.Empty;
}
getLoci(seqIdx: number) {
override getLoci(seqIdx: number) {
const elements: StructureElement.Loci['elements'][0][] = [];
const rI = this.residueIndices.get(seqIdx);
if (rI !== undefined) {

View File

@@ -67,7 +67,7 @@ export class PolymerSequenceWrapper extends SequenceWrapper<StructureUnit> {
return Interval.Empty;
}
getLoci(seqIdx: number) {
override getLoci(seqIdx: number) {
const query = createResidueQuery(this.data.units[0].chainGroupId, this.data.units[0].conformation.operator.name, this.seqId(seqIdx));
return StructureSelection.toLociWithSourceUnits(StructureQuery.run(query, this.data.structure));
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
@@ -7,16 +7,21 @@
*/
import * as React from 'react';
import { Subject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
import { OrderedSet } from '../../mol-data/int';
import { ColorTypeLocation } from '../../mol-geo/geometry/color-data';
import { EveryLoci } from '../../mol-model/loci';
import { StructureElement, StructureProperties, Unit } from '../../mol-model/structure';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginContext } from '../../mol-plugin/context';
import { Representation } from '../../mol-repr/representation';
import { Task } from '../../mol-task';
import { ColorTheme, LocationColor } from '../../mol-theme/color';
import { Color } from '../../mol-util/color';
import { ButtonsType, getButton, getButtons, getModifiers, ModifiersKeys } from '../../mol-util/input/input-observer';
import { MarkerAction } from '../../mol-util/marker-action';
import { memoizeLatest } from '../../mol-util/memoize';
import { PluginUIComponent } from '../base';
import { SequenceWrapper } from './wrapper';
@@ -36,12 +41,19 @@ const DefaultMarkerColors = {
focused: '',
};
type ColorThemeProvider = ColorTheme.Provider<any, string, ColorTypeLocation> | undefined
// TODO: this is somewhat inefficient and should be done using a canvas.
export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
protected parentDiv = React.createRef<HTMLDivElement>();
protected lastMouseOverSeqIdx = -1;
protected highlightQueue = new Subject<{ seqIdx: number, buttons: number, button: number, modifiers: ModifiersKeys }>();
protected markerColors = { ...DefaultMarkerColors };
/** @experimental */
private customColorThemeWrapper: ColorThemeWrapper | undefined = undefined;
/** @experimental Custom function that assigns color to residues in the Sequence component (unless highlighted or selected) */
private customColorFunction: ((idx: number) => string) | undefined = undefined;
protected lociHighlightProvider = (loci: Representation.Loci, action: MarkerAction) => {
const changed = this.props.sequenceWrapper.markResidue(loci.loci, action);
@@ -81,6 +93,14 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
this.updateColors();
this.updateMarker();
});
const experimentalSequenceColorTheme: BehaviorSubject<ColorThemeProvider> | undefined = this.plugin.customUIState.experimentalSequenceColorTheme;
if (experimentalSequenceColorTheme) {
this.subscribe(experimentalSequenceColorTheme, theme => {
if (!theme && !this.customColorThemeWrapper) return;
this.customColorThemeWrapper = ColorThemeWrapper(this.plugin, theme, () => this.forceUpdate());
this.forceUpdate();
});
}
}
updateColors() {
@@ -199,9 +219,12 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
protected getBackgroundColor(seqIdx: number) {
const seqWrapper = this.props.sequenceWrapper;
if (seqWrapper.isHighlighted(seqIdx)) return this.markerColors.highlighted;
if (seqWrapper.isSelected(seqIdx)) return this.markerColors.selected;
if (seqWrapper.isFocused(seqIdx)) return this.markerColors.focused;
if (seqWrapper.isHighlighted(seqIdx) && this.markerColors.highlighted) return this.markerColors.highlighted;
if (seqWrapper.isSelected(seqIdx) && this.markerColors.selected) return this.markerColors.selected;
if (seqWrapper.isFocused(seqIdx) && this.markerColors.focused) return this.markerColors.focused;
if (this.customColorFunction) {
return this.customColorFunction(seqIdx);
}
return '';
}
@@ -322,9 +345,12 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
render() {
const sw = this.props.sequenceWrapper;
const elems: JSX.Element[] = [];
if (this.customColorThemeWrapper) {
this.customColorFunction = this.customColorThemeWrapper.getColorFunction(sw);
}
const hasNumbers = !this.props.hideSequenceNumbers, period = this.sequenceNumberPeriod;
for (let i = 0, il = sw.length; i < il; ++i) {
const label = sw.residueLabel(i);
@@ -355,3 +381,57 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
</div>;
}
}
type ColorThemeWrapper = ReturnType<typeof ColorThemeWrapper>
function ColorThemeWrapper(plugin: PluginContext, theme: ColorThemeProvider, forceUpdate: () => void) {
const tmpLocation = StructureElement.Location.create();
function computeColor(sequenceWrapper: SequenceWrapper.Any, idx: number, locationColor: LocationColor) {
const loci = sequenceWrapper.getLoci(idx);
if (!loci || StructureElement.Loci.isEmpty(loci)) return '';
StructureElement.Loci.getFirstLocation(loci, tmpLocation);
const color = locationColor(tmpLocation, false);
if (color < 0) return ''; // Color(-1) is used as special value NoColor
return Color.toHexStyle(color);
}
const getColorFunction = memoizeLatest((sequenceWrapper: SequenceWrapper.Any) => {
if (!theme) return undefined;
const structure = sequenceWrapper.getLoci(0)?.structure;
if (!structure) return undefined;
let themeColor: LocationColor | undefined = undefined;
if (theme.ensureCustomProperties) {
// The following task runs asynchronously
plugin.runTask(Task.create('Attach custom properties for coloring theme', async runtime => {
try {
await theme.ensureCustomProperties?.attach({ assetManager: plugin.managers.asset, runtime }, { structure });
} catch (err) {
console.warn(`Failed to attach custom properties needed for coloring theme ${theme.name}:`, err);
} finally {
themeColor = theme.factory({ structure }, theme.defaultValues).color;
forceUpdate();
}
}));
} else {
themeColor = theme.factory({ structure }, theme.defaultValues).color;
}
const cache: { [idx: number]: string } = {};
return (idx: number) => {
if (themeColor) { // custom properties ready
return cache[idx] ??= computeColor(sequenceWrapper, idx, themeColor);
} else { // custom properties not ready
return '';
}
};
});
return {
themeName: theme?.name,
getColorFunction,
};
}

View File

@@ -96,30 +96,32 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
render() {
return <div className={'msp-viewport-controls'}>
<div className='msp-viewport-controls-buttons'>
<div className='msp-hover-box-wrapper'>
<div className='msp-semi-transparent-background' />
{this.icon(AutorenewSvg, this.resetCamera, 'Reset Zoom')}
<div className='msp-hover-box-body'>
<div className='msp-flex-column'>
<div className='msp-flex-row'>
<Button onClick={() => this.resetCamera()} disabled={!this.state.isCameraResetEnabled} title='Set camera zoom to fit the visible scene into view'>
Reset Zoom
</Button>
</div>
<div className='msp-flex-row'>
<Button onClick={() => PluginCommands.Camera.OrientAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align principal component axes of the loaded structures to the screen axes (“lay flat”)'>
Orient Axes
</Button>
</div>
<div className='msp-flex-row'>
<Button onClick={() => PluginCommands.Camera.ResetAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align Cartesian axes to the screen axes'>
Reset Axes
</Button>
{this.plugin.config.get(PluginConfig.Viewport.ShowReset) &&
<div className='msp-hover-box-wrapper'>
<div className='msp-semi-transparent-background' />
{this.icon(AutorenewSvg, this.resetCamera, 'Reset Zoom')}
<div className='msp-hover-box-body'>
<div className='msp-flex-column'>
<div className='msp-flex-row'>
<Button onClick={() => this.resetCamera()} disabled={!this.state.isCameraResetEnabled} title='Set camera zoom to fit the visible scene into view'>
Reset Zoom
</Button>
</div>
<div className='msp-flex-row'>
<Button onClick={() => PluginCommands.Camera.OrientAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align principal component axes of the loaded structures to the screen axes (“lay flat”)'>
Orient Axes
</Button>
</div>
<div className='msp-flex-row'>
<Button onClick={() => PluginCommands.Camera.ResetAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align Cartesian axes to the screen axes'>
Reset Axes
</Button>
</div>
</div>
</div>
<div className='msp-hover-box-spacer'></div>
</div>
<div className='msp-hover-box-spacer'></div>
</div>
}
{this.plugin.config.get(PluginConfig.Viewport.ShowScreenshotControls) && <div>
<div className='msp-semi-transparent-background' />
{this.icon(CameraOutlinedSvg, this.toggleScreenshotExpanded, 'Screenshot / State Snapshot', this.state.isScreenshotExpanded)}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -97,7 +97,7 @@ export class ViewportHelpContent extends PluginUIComponent<{ selectOnly?: boolea
return <>
{(!this.props.selectOnly && this.plugin.canvas3d) && <HelpGroup key='trackball' header='Moving in 3D'>
<BindingsHelp bindings={this.plugin.canvas3d.props.trackball.bindings} />
<BindingsHelp bindings={this.plugin.canvas3d.attribs.trackball.bindings} />
</HelpGroup>}
{!!interactionBindings && <HelpGroup key='interactions' header='Mouse & Key Controls'>
<BindingsHelp bindings={interactionBindings} />

View File

@@ -5,7 +5,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { produce } from 'immer';
import { produce } from '../../mol-util/produce';
import { throttleTime } from 'rxjs';
import { Canvas3DContext, Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
import { PluginCommands } from '../../mol-plugin/commands';

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 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 * from './behavior/behavior';
@@ -13,6 +14,7 @@ import * as StaticMisc from './behavior/static/misc';
import * as DynamicRepresentation from './behavior/dynamic/representation';
import * as DynamicCamera from './behavior/dynamic/camera';
import * as DynamicState from './behavior/dynamic/state';
import * as DynamicCustomProps from './behavior/dynamic/custom-props';
export const BuiltInPluginBehaviors = {
@@ -25,5 +27,6 @@ export const BuiltInPluginBehaviors = {
export const PluginBehaviors = {
Representation: DynamicRepresentation,
Camera: DynamicCamera,
State: DynamicState,
CustomProps: DynamicCustomProps
};

View File

@@ -79,6 +79,13 @@ export const FocusLoci = PluginBehavior.create<FocusLociProps>({
return;
}
// Prevent when interaction props are set
const snapshotKey = current.repr?.props?.snapshotKey?.trim() ?? '';
const markdownCommands = current.repr?.props?.markdownCommands;
if (snapshotKey || (typeof markdownCommands === 'object' && Object.keys(markdownCommands).length > 0)) {
return;
}
if (Binding.match(binding, button, modifiers)) {
const loci = Loci.normalize(current.loci, this.ctx.managers.interactivity.props.granularity);
this.ctx.managers.camera.focusLoci(loci, this.params);

View File

@@ -290,6 +290,15 @@ export const FocusLoci = PluginBehavior.create<FocusLociProps>({
return;
}
// Support executing markdown commands associated with a visual
const markdownCommands = current.repr?.props?.markdownCommands;
if (!this.ctx.selectionMode && matched && typeof markdownCommands === 'object') {
if (Object.keys(markdownCommands).length > 0) {
this.ctx.managers.markdownExtensions.tryExecute('click', markdownCommands);
return;
}
}
// only apply structure focus for appropriate granularity
const { granularity } = this.ctx.managers.interactivity.props;
if (granularity !== 'residue' && granularity !== 'element') return;

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { PluginBehavior } from '../behavior';
import { Binding } from '../../../mol-util/binding';
import { ModifiersKeys } from '../../../mol-util/input/input-observer';
const M = ModifiersKeys;
const Key = Binding.TriggerKey;
const DefaultSnapshotControlsBindings = {
next: Binding([
Key('ArrowRight', M.create({ control: true })),
]),
previous: Binding([
Key('ArrowLeft', M.create({ control: true })),
]),
first: Binding([
Key('ArrowUp', M.create({ control: true })),
]),
last: Binding([
Key('ArrowDown', M.create({ control: true })),
]),
};
const SnapshotControlsParams = {
bindings: PD.Value(DefaultSnapshotControlsBindings, { isHidden: true }),
};
type SnapshotControlsProps = PD.Values<typeof SnapshotControlsParams>
export const SnapshotControls = PluginBehavior.create<SnapshotControlsProps>({
name: 'snapshot-controls',
category: 'interaction',
ctor: class extends PluginBehavior.Handler<SnapshotControlsProps> {
register(): void {
this.subscribeObservable(this.ctx.behaviors.interaction.keyReleased, ({ code, modifiers, key }) => {
if (!this.ctx.canvas3d || this.ctx.isBusy) return;
// include defaults for backwards state compatibility
const b = this.params.bindings;
const { snapshot } = this.ctx.managers;
if (Binding.matchKey(b.next, code, modifiers, key)) {
snapshot.applyNext(1);
}
if (Binding.matchKey(b.previous, code, modifiers, key)) {
snapshot.applyNext(-1);
}
if (Binding.matchKey(b.first, code, modifiers, key)) {
const e = snapshot.state.entries.get(0)!;
const s = snapshot.setCurrent(e.snapshot.id);
if (s) return this.ctx.state.setSnapshot(s);
}
if (Binding.matchKey(b.last, code, modifiers, key)) {
const e = snapshot.state.entries.get(snapshot.state.entries.size - 1)!;
const s = snapshot.setCurrent(e.snapshot.id);
if (s) return this.ctx.state.setSnapshot(s);
}
});
}
},
params: () => SnapshotControlsParams,
display: { name: 'Snapshot Controls' }
});

View File

@@ -52,6 +52,7 @@ export const PluginConfig = {
EmdbHeaderServer: item('volume-streaming.emdb-header-server', 'https://files.wwpdb.org/pub/emdb/structures'),
},
Viewport: {
ShowReset: item('viewer.show-reset-button', true),
ShowExpand: item('viewer.show-expand-button', true),
ShowControls: item('viewer.show-controls-button', true),
ShowSettings: item('viewer.show-settings-button', true),

View File

@@ -5,7 +5,7 @@
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { produce, setAutoFreeze } from 'immer';
import { produce } from '../mol-util/produce';
import { List } from 'immutable';
import { merge, Subscription } from 'rxjs';
import { debounceTime, filter, take, throttleTime } from 'rxjs/operators';
@@ -382,6 +382,7 @@ export class PluginContext {
}
this.subs = [];
this.managers.markdownExtensions.audio.dispose();
this.animationLoop.stop();
this.commands.dispose();
this.canvas3d?.dispose();
@@ -529,11 +530,6 @@ export class PluginContext {
}
constructor(public spec: PluginSpec) {
// the reason for this is that sometimes, transform params get modified inline (i.e. palette.valueLabel)
// and freezing the params object causes "read-only exception"
// TODO: is this the best place to do it?
setAutoFreeze(false);
setSaccharideCompIdMapType(this.config.get(PluginConfig.Structure.SaccharideCompIdMapType) ?? 'default');
}
}

View File

@@ -124,6 +124,7 @@ export const DefaultPluginSpec = (): PluginSpec => ({
PluginSpec.Behavior(PluginBehaviors.Camera.FocusLoci),
PluginSpec.Behavior(PluginBehaviors.Camera.CameraAxisHelper),
PluginSpec.Behavior(PluginBehaviors.Camera.CameraControls),
PluginSpec.Behavior(PluginBehaviors.State.SnapshotControls),
PluginSpec.Behavior(StructureFocusRepresentation),
PluginSpec.Behavior(PluginBehaviors.CustomProps.StructureInfo),

View File

@@ -6,7 +6,7 @@
* @author Adam Midlik <midlik@gmail.com>
*/
import { produce } from 'immer';
import { produce } from '../mol-util/produce';
import { merge } from 'rxjs';
import { Camera } from '../mol-canvas3d/camera';
import { Canvas3DContext, Canvas3DParams, Canvas3DProps } from '../mol-canvas3d/canvas3d';
@@ -27,6 +27,7 @@ import { PluginConfig } from './config';
import { PluginContext } from './context';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { Scheduler } from '../mol-task';
import { memoizeLatest } from '../mol-util/memoize';
export { PluginState };
@@ -118,6 +119,11 @@ class PluginState extends PluginComponent {
durationMs: snapshot.camera.transitionStyle === 'animate' ? snapshot.camera.transitionDurationInMs : undefined,
});
}
if (typeof snapshot?.onLoadMarkdownCommands === 'object' && Object.keys(snapshot.onLoadMarkdownCommands).length > 0) {
this.plugin.managers.markdownExtensions.tryExecute('click', snapshot.onLoadMarkdownCommands);
}
if (snapshot.startAnimation) {
this.animation.start();
return;
@@ -147,6 +153,10 @@ class PluginState extends PluginComponent {
durationMs: frame.camera.transitionStyle === 'animate' ? frame.camera.transitionDurationInMs : undefined,
});
}
if (!frameIndex && typeof snapshot?.onLoadMarkdownCommands === 'object' && Object.keys(snapshot.onLoadMarkdownCommands).length > 0) {
this.plugin.managers.markdownExtensions.tryExecute('click', snapshot.onLoadMarkdownCommands);
}
}
updateTransform(state: State, a: StateTransform.Ref, params: any, canUndo?: string | boolean) {
@@ -241,6 +251,7 @@ namespace PluginState {
},
durationInMs?: number,
transition?: StateTransition,
onLoadMarkdownCommands?: Record<string, any>
}
export interface StateTransition {
@@ -254,7 +265,23 @@ namespace PluginState {
}[],
}
export function getStateTransitionDuration(snapshot: Snapshot): number | undefined {
export const getMinFrameDuration = memoizeLatest((snapshot: Snapshot | undefined): number => {
if (!snapshot) return 1000 / 60;
const { transition } = snapshot;
if (!transition) return 1000 / 60;
let minDuration = Infinity;
for (const frame of transition.frames) {
if (frame.durationInMs > 0 && frame.durationInMs < minDuration) {
minDuration = frame.durationInMs;
}
}
if (!Number.isFinite(minDuration)) return 1000 / 60;
return minDuration;
});
export const getStateTransitionDuration = memoizeLatest((snapshot: Snapshot | undefined): number | undefined => {
if (!snapshot) return undefined;
const { transition } = snapshot;
if (!transition) return undefined;
let totalDuration = 0;
@@ -263,7 +290,20 @@ namespace PluginState {
totalDuration += frame.durationInMs;
}
return totalDuration;
}
});
export const getStateTransitionFrameTime = memoizeLatest((snapshot: Snapshot | undefined, frameIndex: number | undefined): number => {
if (!snapshot || frameIndex === undefined) return 0;
const { transition } = snapshot;
if (!transition) return 0;
let currentDuration = 0;
for (let i = 0; i < frameIndex; i++) {
if (transition.frames.length <= i) break;
const frame = transition.frames[i];
currentDuration += frame.durationInMs;
}
return currentDuration;
});
export function getStateTransitionFrameIndex(snapshot: Snapshot, timestamp: number): number | undefined {
const { transition } = snapshot;

View File

@@ -507,7 +507,7 @@ namespace Representation {
}
let _EmptyRepresentation: Representation.Any | undefined = undefined;
Object.defineProperty(Representation, "Empty", {
Object.defineProperty(Representation, 'Empty', {
get: () => {
return _EmptyRepresentation ??= Representation.createEmpty();
}

View File

@@ -276,7 +276,8 @@ const atomProperty = {
instanceId: atomProp(Type.Str, 'Canonical name of the symmetry operator applied to this element.'),
operatorKey: atomProp(Type.Num, 'Key of the symmetry operator applied to this element.'),
modelIndex: atomProp(Type.Num, 'Index of the model in the input file.'),
modelLabel: atomProp(Type.Str, 'Label/header of the model in the input file.')
modelLabel: atomProp(Type.Str, 'Label/header of the model in the input file.'),
modelEntryId: atomProp(Type.Str, 'Entry ID of the model (e.g., PDB ID).')
},
topology: {

View File

@@ -309,6 +309,7 @@ const symbols = [
D(MolScript.structureQuery.atomProperty.core.operatorKey, atomProp(StructureProperties.unit.operator_key)),
D(MolScript.structureQuery.atomProperty.core.modelIndex, atomProp(StructureProperties.unit.model_index)),
D(MolScript.structureQuery.atomProperty.core.modelLabel, atomProp(StructureProperties.unit.model_label)),
D(MolScript.structureQuery.atomProperty.core.modelEntryId, atomProp(StructureProperties.unit.model_entry_id)),
D(MolScript.structureQuery.atomProperty.core.atomKey, (ctx, xs) => {
const e = (xs && xs[0] && xs[0](ctx) as any) || ctx.element;
return cantorPairing(e.unit.id, e.element);

View File

@@ -215,6 +215,7 @@ export const SymbolTable = [
Alias(MolScript.structureQuery.atomProperty.core.operatorKey, 'atom.op-key'),
Alias(MolScript.structureQuery.atomProperty.core.modelIndex, 'atom.model-index'),
Alias(MolScript.structureQuery.atomProperty.core.modelLabel, 'atom.model-label'),
Alias(MolScript.structureQuery.atomProperty.core.modelEntryId, 'atom.model-entry-id'),
Alias(MolScript.structureQuery.atomProperty.core.atomKey, 'atom.key'),
Alias(MolScript.structureQuery.atomProperty.core.bondCount, 'atom.bond-count'),

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
@@ -80,8 +80,6 @@ interface StateObjectCell<T extends StateObject = StateObject, F extends StateTr
values: any
} | undefined,
paramsNormalizedVersion: string,
dependencies: {
dependentBy: StateObjectCell[],
dependsOn: StateObjectCell[]

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
@@ -381,7 +381,6 @@ class State {
definition: {},
values: {}
},
paramsNormalizedVersion: root.version,
dependencies: { dependentBy: [], dependsOn: [] },
cache: { }
});
@@ -666,7 +665,6 @@ function addCellsVisitor(transform: StateTransform, _: any, { ctx, added, visite
state: { ...transform.state },
errorText: void 0,
params: void 0,
paramsNormalizedVersion: '',
dependencies: { dependentBy: [], dependsOn: [] },
cache: void 0
};
@@ -849,9 +847,9 @@ function resolveParams(ctx: UpdateContext, transform: StateTransform, src: State
const prms = transform.transformer.definition.params;
const definition = prms ? prms(src, ctx.parent.globalContext) : {};
if (cell.paramsNormalizedVersion !== transform.version) {
if (transform.version !== (transform as any)._normalized_param_version) {
(transform.params as any) = ParamDefinition.normalizeParams(definition, transform.params, 'all');
cell.paramsNormalizedVersion = transform.version;
(transform as any)._normalized_param_version = transform.version;
} else {
const defaultValues = ParamDefinition.getDefaultValues(definition);
(transform.params as any) = transform.params

View File

@@ -11,7 +11,7 @@ import { StateObject, StateObjectCell, StateObjectSelector, StateObjectRef } fro
import { StateTransform } from '../transform';
import { StateTransformer } from '../transformer';
import { State } from '../state';
import { produce } from 'immer';
import { produce } from '../../mol-util/produce';
export { StateBuilder };

View File

@@ -375,6 +375,14 @@ namespace StateSelection {
const first = children.first();
if (first && state.transforms.get(first).transformer.definition.isDecorator) return tryFindDecorator(state, first, transformer);
}
export function getDecorated<T extends StateObject>(state: State, root: StateTransform.Ref): StateObjectCell<T> {
const children = state.tree.children.get(root);
if (children.size !== 1) return state.cells.get(root) as any;
const first = children.first();
if (first && state.transforms.get(first).transformer.definition.isDecorator) return getDecorated(state, first);
return state.cells.get(root) as any;
}
}
export { StateSelection };

View File

@@ -6,7 +6,7 @@
import { StateTransformer } from './transformer';
import { UUID } from '../mol-util';
import { hashFnv256o } from '../mol-data/util';
import { hashMurmur128o } from '../mol-data/util';
export { Transform as StateTransform };
@@ -175,12 +175,12 @@ namespace Transform {
export function setParamsHashVersion(t: Transform) {
let version: string;
try {
version = hashFnv256o(t.params ?? _emptyParams);
version = hashMurmur128o(t.params ?? _emptyParams);
} catch {
const pToJson = t.transformer.definition.customSerialization
? t.transformer.definition.customSerialization.toJSON
: _id;
version = hashFnv256o(pToJson(t.params ?? _emptyParams));
version = hashMurmur128o(pToJson(t.params ?? _emptyParams));
}
(t as { version: string }).version = version;
}

View File

@@ -83,6 +83,15 @@ class AssetManager {
}
}
tryFindFilename(name: string): Asset | undefined {
const it = this._assets.values();
while (true) {
const { done, value } = it.next();
if (done) break;
if (value.file.name === name) return value.asset;
}
}
set(asset: Asset, file: File, options?: { isStatic?: boolean, tag?: string }) {
this._assets.set(asset.id, { asset, file, refCount: 0, tag: options?.tag, isStatic: options?.isStatic });
}

View File

@@ -80,7 +80,7 @@ export namespace ColorScale {
}
} else {
switch (type) {
case 'continuous': color = (value: number) => valueToColor(value, colors, min, max, diff); break;
case 'continuous': color = (value: number) => valueToColor(value, colors, min, diff); break;
case 'discrete': color = (value: number) => valueToDiscreteColor(value, colors, min, max, diff); break;
}
}
@@ -113,8 +113,8 @@ export namespace ColorScale {
return Color.interpolate(src[i - 1], src[i], t1);
}
function valueToColor(value: number, colors: ColorListEntry[], min: number, max: number, diff: number) {
const t = Math.min(colors.length - 1, Math.max(0, ((value - min) / diff) * colors.length - 1));
function valueToColor(value: number, colors: ColorListEntry[], min: number, diff: number) {
const t = Math.min(colors.length - 1, Math.max(0, ((value - min) / diff) * (colors.length - 1)));
const tf = Math.floor(t);
const c1 = colors[tf] as Color;
const c2 = colors[Math.ceil(t)] as Color;

View File

@@ -5,13 +5,13 @@
*/
export class ErrorContext {
private errors: { [tag: string]: any[] } = Object.create(null);
private errors: { [tag: string]: string[] } = Object.create(null);
get(tag: string): ReadonlyArray<any> {
get(tag: string): ReadonlyArray<string> {
return this.errors[tag] ?? [];
}
add(tag: string, error: any) {
add(tag: string, error: string) {
if (tag in this.errors && Array.isArray(this.errors[tag])) {
this.errors[tag].push(error);
} else {

View File

@@ -7,6 +7,7 @@
export async function fileToDataUri(file: File): Promise<string> {
const filename = file.name.toLowerCase() || 'file';
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].some(ext => filename.endsWith(`.${ext}`));
const isAudio = ['mp3', 'wav', 'ogg'].some(ext => filename.endsWith(`.${ext}`));
let type = 'application/octet-stream';
if (isImage) {
@@ -19,6 +20,16 @@ export async function fileToDataUri(file: File): Promise<string> {
type = `image/${ext}`;
break;
}
} else if (isAudio) {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'mp3':
type = 'audio/mpeg';
break;
default:
type = `audio/${ext}`;
break;
}
}
const bytes = await file.arrayBuffer();

View File

@@ -534,7 +534,7 @@ export namespace ParamDefinition {
if (a === undefined) return { ...b };
if (b === undefined) return { ...a };
const o = Object.create(null);
const o = {} as any;
for (const k of Object.keys(params)) {
o[k] = mergeParam(params[k], a[k], b[k]);
}
@@ -587,7 +587,7 @@ export namespace ParamDefinition {
if (p.type === 'value') {
return value;
} else if (p.type === 'group') {
const ret = Object.create(null);
const ret = {} as any;
for (const key of Object.keys(p.params)) {
const param = p.params[key];
if (value[key] === void 0) {
@@ -638,7 +638,7 @@ export namespace ParamDefinition {
return defaultIfUndefined ? getDefaultValues(p) : value;
}
const ret = Object.create(null);
const ret = {} as any;
for (const key of Object.keys(p)) {
const param = p[key];
if (value[key] === void 0) {

View File

@@ -5,7 +5,7 @@
*/
import { ParamDefinition as PD } from './param-definition';
import { produce } from 'immer';
import { produce } from './produce';
import { Mutable } from './type-helpers';
export interface ParamMapping<S, T, Ctx> {

15
src/mol-util/produce.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { create } from 'mutative/dist/index.js';
/** Apply changes to an immutable-like object */
export function produce<T>(base: T, recipe: (draft: T) => T | void): T {
if (typeof base === 'object' && !('prototype' in (base as any))) {
return create({ ...base }, recipe as any) as T;
}
return create(base, recipe as any) as T;
}

View File

@@ -92,7 +92,7 @@ async function createDataContext(file: FileHandle): Promise<Data.DataContext> {
return {
file,
header,
spacegroup: SpacegroupCell.create(header.spacegroup.number, Vec3.ofArray(header.spacegroup.size), Vec3.scale(Vec3.zero(), Vec3.ofArray(header.spacegroup.angles), Math.PI / 180)),
spacegroup: SpacegroupCell.create(header.spacegroup.number || 'P 1', Vec3.ofArray(header.spacegroup.size), Vec3.scale(Vec3.zero(), Vec3.ofArray(header.spacegroup.angles), Math.PI / 180)),
dataBox: { a: origin, b: Coords.add(origin, dimensions) },
sampling: header.sampling.map((s, i) => createSampling(header, i, dataOffset))
};