Compare commits

...

56 Commits

Author SHA1 Message Date
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
dsehnal
1b5a7d9546 5.0.0-dev.5 2025-08-13 18:44:02 +02:00
David Sehnal
f165cc4629 MVS: Animations (#1606)
* object hash

* hashed StateTransform.version

* StateTree.reuseTransformParams

* State animation data model

* mvs animation tree schema and builder

* generate animation

* snapshot animation ui

* async animation generation

* ui tweak

* ui tweak

* wrap loadMVS in task

* state snapshots animation

* snapshot transition animation

* autoplay transition

* vector and rotation matrix interpolation

* local rotation transform

* fixes and better demo

* unused import

* tweak

* changelog

* headers

* type => kind

* mat4 interpolation

* use proper time in animation loop

* animated label opacity

* typo

* add postprocessing to demo

* fix mvs postprocessing params

* add transform_matrix interpolation

* tweak

* generalize vector interpolation

* Color.interpolateHcl

* resetCanvasProps

* rename from/to to start/end

* transform def

* add frequency param

* update interpolations

* cache rotation, do not apply noise to last frame

* local_rotation => rotation_center

* changelog

* fix build

* add animation.duration_ms

* PR feedback

* add hsl color space

* default canvas bg

* scalar list interpolation

* color interpolation props
2025-08-13 18:41:56 +02:00
Alexander Rose
db247d6fbd remove mat4 allocation 2025-08-12 22:49:14 -07:00
Alexander Rose
138796862b Merge pull request #1589 from giagitom/dot-volume-improvements
Dot volume representation improvements
2025-08-10 10:44:39 -07:00
Alexander Rose
1b236f1ae5 cleanup 2025-08-10 10:42:17 -07:00
Alexander Rose
b6c2e25395 cleanup 2025-08-10 10:37:44 -07:00
giagitom
b7816986aa lint-fix 2025-08-10 13:07:54 +02:00
giagitom
437c70a75a Apply suggestions 2025-08-10 13:05:08 +02:00
giagitom
de85e0fbae Add extractBasis helper to mat4 2025-08-10 12:49:09 +02:00
giagitom
c527b59782 optimization 2025-08-09 21:01:33 +02:00
giagitom
3bbbac66c7 lint fix 2025-08-09 12:36:29 +02:00
giagitom
c0980bf18a Improvements in perturbation obtainment 2025-08-08 23:21:54 +02:00
giagitom
45eab19493 refactor 2025-08-08 16:57:56 +02:00
giagitom
1e2a5a5bfd - Rename property and refactor
- handle  non-orthogonal cell
2025-08-08 16:45:22 +02:00
giagitom
45edfa8014 Merge branch 'master' of https://github.com/molstar/molstar into dot-volume-improvements 2025-08-08 14:38:06 +02:00
David Sehnal
899203c855 MVS: surface_type option (#1603) 2025-08-07 11:15:44 +02:00
김주호
ef823b066b Change PDB parsing to use four-letter residue names (#1602)
* Add is4LetterResidueName option for parsing .pdb

* update metadata for request pullrequest

* Always parse PDB files using four-letter residue names. (#1601) (#1602)

* Write changelog about parsing resname 4-letter

* Update src/mol-plugin-state/formats/trajectory.ts

* Update src/mol-model-formats/structure/pdb/to-cif.ts

---------

Co-authored-by: David Sehnal <dsehnal@users.noreply.github.com>
2025-08-06 07:36:20 +02:00
dsehnal
33dc2015df 5.0.0-dev.4 2025-08-05 09:55:55 +02:00
dsehnal
fcf5ea420b npm audit 2025-08-05 09:54:34 +02:00
dsehnal
8d97327f8d MVS: fix passing custom primitive params 2025-08-05 09:53:58 +02:00
midlik
abc7ebba3e MVS: Fix MVSInlinePrimitiveData param type (#1592) 2025-08-03 17:48:19 +02:00
David Sehnal
73d593907e MVS cavnas molstar_postprocessing (#1598) 2025-08-03 12:04:53 +02:00
giagitom
50a820b0ae - Add perturbatePositions property
- Fixes and improvements
2025-07-28 16:10:52 +02:00
giagitom
0cb2c3621b Dot volume representation improvements 2025-07-28 02:07:54 +02:00
101 changed files with 4785 additions and 465 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,25 +21,39 @@ 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 properties `molstar_enable_outline/shadow/ssao`, `molstar_outline/shadow/ssao_params`
- `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), and fog
- `clip` node support for structure and volume representations
- `grid_slice` representation support for volumes
- Support tethers and background for primitive labels
- Support `snapshot_key` parameter on primitives that enables transition between states via clicking on 3D objects
- Inline selectors and MVS annotations support `instance_id`
- Support `matrix` on transform params
- Support `surface_type` (`molecular` / `gaussian`) on for `surface` representation nodes
- Add `instance` node type
- Add `transform.rotation_center` property that enables rotating an object around its centroid or a specific point
- 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
- 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))
@@ -53,7 +69,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
@@ -63,6 +82,24 @@ Note that since we don't clearly distinguish between a public and private interf
- Add async, non-blocking picking (only WebGL2)
- Refactor `Canvas3dInteractionHelper` internals to use async picking for move events
- Add `enable` param for post-processing effects. If false, no effects are applied.
- Dot volume representation improvements
- Add positional perturbation to avoid camera artifacts
- Fix handling of negative isoValues by considering only volume cells with values lower than isoValue (#1559)
- Fix volume-value size theme
- Change the parsing of residue names in PDB files from 3-letter to 4-letter.
- Support versioning transform using a hash function in `mol-state`
- Support for "state snapshot transitions"
- Add `PluginState.Snapshot.transition` that enables associating a state snapshot with a list states that can be animated
- Add `AnimateStateSnapshotTransition` animation
- Update the snapshots UI to support this feature
- Use "proper time" in the animation loop to prevent animation skips during blocking operations (e.g., shader complication)
- 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`
## [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.

74
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "molstar",
"version": "5.0.0-dev.3",
"version": "5.0.0-dev.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "molstar",
"version": "5.0.0-dev.3",
"version": "5.0.0-dev.10",
"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",
@@ -1171,9 +1171,9 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2763,7 +2763,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=8"
}
@@ -3021,7 +3021,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"devOptional": true
"dev": true
},
"node_modules/basic-auth": {
"version": "2.0.1",
@@ -3081,7 +3081,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -3494,7 +3494,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"devOptional": true
"dev": true
},
"node_modules/concurrently": {
"version": "9.1.2",
@@ -4127,7 +4127,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"devOptional": true
"dev": true
},
"node_modules/encodeurl": {
"version": "2.0.0",
@@ -5012,7 +5012,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"devOptional": true
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -5170,7 +5170,7 @@
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"devOptional": true,
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -5203,7 +5203,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5214,7 +5214,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"devOptional": true,
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -5297,7 +5297,7 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"devOptional": true
"dev": true
},
"node_modules/graphemer": {
"version": "1.4.0",
@@ -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",
@@ -5621,7 +5612,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=0.8.19"
}
@@ -5630,7 +5621,7 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"devOptional": true,
"dev": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
@@ -5838,7 +5829,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=8"
}
@@ -6080,7 +6071,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"devOptional": true
"dev": true
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
@@ -7980,7 +7971,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"devOptional": true,
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -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",
@@ -8357,7 +8357,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9162,7 +9162,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"devOptional": true
"dev": true
},
"node_modules/safe-identifier": {
"version": "0.4.2",
@@ -9518,7 +9518,7 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"devOptional": true
"dev": true
},
"node_modules/simple-git": {
"version": "3.28.0",
@@ -9639,7 +9639,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"devOptional": true,
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -9721,7 +9721,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"devOptional": true,
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -10454,7 +10454,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true
"dev": true
},
"node_modules/util.promisify": {
"version": "1.1.3",
@@ -10577,7 +10577,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"devOptional": true,
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},

View File

@@ -1,6 +1,6 @@
{
"name": "molstar",
"version": "5.0.0-dev.3",
"version": "5.0.0-dev.10",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {
@@ -121,7 +121,8 @@
"Andy Turner <agdturner@gmail.com>",
"Lukáš Polák <admin@lukaspolak.cz>",
"Chetan Mishra <chetan.s115@gmail.com>",
"Zach Charlop-Powers <zach.charlop.powers@gmail.com>"
"Zach Charlop-Powers <zach.charlop.powers@gmail.com>",
"Kim Juho <juho_kim@outlook.com>"
],
"license": "MIT",
"devDependencies": {
@@ -165,9 +166,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

@@ -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

@@ -30,8 +30,8 @@ import { ExampleMol } from './example-data';
import './index.html';
import { jsonCifToMolfile } from './molfile';
import { RGroupName } from './r-groups';
import { SingleTaskQueue } from './utils';
import { molfileToJSONCif } from '../../extensions/json-cif/utils';
import { SingleTaskQueue } from '../../mol-util/single-task-queue';
async function init(target: HTMLElement | string, molfile: string = ExampleMol) {
const root = typeof target === 'string' ? document.getElementById(target)! : target;

View File

@@ -0,0 +1,360 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { MVSData_States } from '../../../extensions/mvs/mvs-data';
import { createMVSBuilder, Structure as MVSStructure, Root } from '../../../extensions/mvs/tree/mvs/mvs-builder';
import { MVSNodeParams } from '../../../extensions/mvs/tree/mvs/mvs-tree';
import { ColorT } from '../../../extensions/mvs/tree/mvs/param-types';
import { Mat4 } from '../../../mol-math/linear-algebra';
const Colors = {
'1cbs': '#4577B2' as ColorT,
'ligand-away': '#F3794C' as ColorT,
'ligand-docked': '#B9E3A0' as ColorT,
};
const Steps = [
{
header: 'Animation Demo',
key: 'intro',
description: `### Molecular Animation
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 => {
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
polymer(_1cbs, { color: Colors['1cbs'] });
const prims = _1cbs.primitives({
ref: 'prims',
label_opacity: 0,
});
prims.label({ text: 'Animation Demo', position: { label_asym_id: 'A' }, label_size: 10 });
const anim = builder.animation({
custom: {
molstar_trackball: {
name: 'rock',
params: { speed: 0.5 },
}
}
});
anim.interpolate({
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
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
// const surface = poly.representation({
// type: 'surface',
// surface_type: 'gaussian',
// }).opacity({ opacity: 0 });
// _1cbs.component({ selector: 'ligand' })
// .representation({ type: 'ball_and_stick' })
// .opacity({ opacity: 0 });
// surface.clip({
// ref: 'clip',
// type: 'plane',
// point: [22.0, 15, 0],
// normal: [0, 0, 1],
// });
return builder;
},
camera: {
position: [-11.49, -37.05, 15.78],
target: [15.85, 17.26, 24.32],
up: [-0.88, 0.4, 0.26],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Ligand Docking',
description: `Animate ligand moving to the binding site`,
linger_duration_ms: 2500,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
const [poly,] = polymer(_1cbs, { color: Colors['1cbs'] });
const surface = poly.representation({
type: 'surface',
surface_type: 'gaussian',
});
_1cbs.component({ selector: 'ligand' })
.transform({
ref: 'xform',
translation: [5, 20, -20],
rotation: [1, 0, 0, 0, 1, 0, 0, 0, 1],
rotation_center: 'centroid',
})
.representation({ type: 'ball_and_stick' })
.color({ ref: 'ligand-color', color: 'red' });
surface.clip({
ref: 'clip',
type: 'plane',
point: [22.0, 15, 0],
normal: [0, 0, 1],
});
const anim = builder.animation();
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
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',
duration_ms: 2000,
property: 'translation',
end: [0, 0, 0],
noise_magnitude: 1,
});
anim.interpolate({
kind: 'rotation_matrix',
target_ref: 'xform',
duration_ms: 2000,
property: 'rotation',
noise_magnitude: 0.2,
});
anim.interpolate({
kind: 'color',
target_ref: 'ligand-color',
duration_ms: 2000,
property: 'color',
end: Colors['ligand-docked'],
});
return builder;
},
camera: {
position: [-30.63, 77.29, 2.28],
target: [19.16, 26.15, 22.82],
up: [0.69, 0.71, 0.09],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Highlight & Opacity',
description: `Animate emissive, opacity and transform properties`,
linger_duration_ms: 2000,
transition_duration_ms: 0,
state: (): Root => {
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
const [poly,] = polymer(_1cbs, { color: Colors['1cbs'] });
poly.representation({
type: 'surface',
surface_type: 'gaussian'
}).opacity({ ref: 'opacity', opacity: 1 }).color({ ref: 'surface-color', color: 'white' });
_1cbs.component({ selector: 'ligand' })
.transform({ ref: 'xform', translation: [0, 0, 0] })
.representation({
ref: 'repr',
type: 'ball_and_stick',
custom: {
molstar_representation_params: {
emissive: 0,
}
}
})
.color({ color: Colors['ligand-docked'] });
const primitives = builder.primitives({
ref: 'primitives',
instances: [
Mat4.identity()
],
opacity: 0,
});
primitives.ellipsoid({
center: [0, 0, 0],
radius: [2, 3, 2.5],
color: 'red'
});
const anim = builder.animation();
anim.interpolate({
kind: 'scalar',
target_ref: 'repr',
duration_ms: 1000,
property: ['custom', 'molstar_representation_params', 'emissive'],
end: 0.2,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'opacity',
duration_ms: 1000,
frequency: 2,
alternate_direction: true,
property: 'opacity',
end: 0,
});
anim.interpolate({
kind: 'transform_matrix',
target_ref: 'primitives',
property: ['instances', 0],
translation_start: [20.24, 29.64, 14.85],
translation_end: [21.84, 21.71, 27.04],
translation_frequency: 4,
pivot: [0, 0, 0],
rotation_noise_magnitude: 0.2,
scale_end: [0.01, 0.01, 0.01],
duration_ms: 1000,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'primitives',
duration_ms: 1000,
property: 'opacity',
end: 1,
});
anim.interpolate({
kind: 'color',
target_ref: 'surface-color',
duration_ms: 2000,
property: 'color',
palette: {
kind: 'continuous',
colors: ['white', Colors['1cbs'], 'white'],
}
});
return builder;
},
camera: {
position: [6.92, 47.17, 10.68],
target: [21.79, 22.2, 23.43],
up: [0.8, 0.57, 0.2],
} satisfies MVSNodeParams<'camera'>,
}
];
function structure(builder: Root, id: string): MVSStructure {
return builder
.download({ url: pdbUrl(id) })
.parse({ format: 'bcif' })
.modelStructure();
}
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 });
return [component, reprensentation] as const;
}
function pdbUrl(id: string) {
return `https://www.ebi.ac.uk/pdbe/entry-files/download/${id.toLowerCase()}.bcif`;
}
export function buildStory(): MVSData_States {
const snapshots = Steps.map((s, i) => {
const builder = s.state();
if (s.camera) builder.camera(s.camera);
builder.canvas({
custom: {
molstar_postprocessing: {
enable_outline: true,
enable_ssao: true,
}
}
});
const description = i > 0 ? `${s.description}\n\n[Go to start](#intro)` : s.description;
return builder.getSnapshot({
title: s.header,
key: s.key,
description,
description_format: 'markdown',
linger_duration_ms: s.linger_duration_ms ?? 500,
transition_duration_ms: s.transition_duration_ms ?? 1000,
});
});
return {
kind: 'multiple',
snapshots,
metadata: {
title: 'Animation Showcase',
version: '1.0',
timestamp: new Date().toISOString(),
}
};
}

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,14 @@
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: '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;

View File

@@ -0,0 +1,958 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Ludovic Autin <autin@scripps.edu>
*/
import { decodeColor } from '../../../extensions/mvs/helpers/utils';
import { MVSData_States } from '../../../extensions/mvs/mvs-data';
import { createMVSBuilder, Structure as MVSStructure, Root } from '../../../extensions/mvs/tree/mvs/mvs-builder';
import { MVSNodeParams } from '../../../extensions/mvs/tree/mvs/mvs-tree';
import { Mat4 } from '../../../mol-math/linear-algebra/3d/mat4';
import { Vec3 } from '../../../mol-math/linear-algebra/3d/vec3';
import { MolScriptBuilder as MS } from '../../../mol-script/language/builder';
import { formatMolScript } from '../../../mol-script/language/expression-formatter';
// 1pmb->1mbn
const align = Mat4.fromArray(Mat4.zero(), [0.4634187130865737, -0.7131589697034304, 0.5259728687171936, 0, -0.22944227902330105, -0.6698811108214233, -0.7061273127008398, 0, 0.8559202154942049, 0.2065522332899299, -0.4740643150728161, 0, -52.54880970106205, 37.49099778180445, -6.133850309914719, 1], 0);
// 1mbo->1myf
const alignmbo = Mat4.fromArray(Mat4.zero(), [-0.8334619943964441, -0.512838061396133, -0.20576353166796402, 0, -0.20145089001561267, 0.628743285359846, -0.7510655776229758, 0, 0.5145474196737698, -0.5845332204089626, -0.6273453801378679, 0, 11.864847328611186, -1.5261713438028912, 23.638919347623467, 1], 0);
const ill_color = (color: string, carbonLightness: number) => ({
molstar_color_theme_name: 'illustrative',
molstar_color_theme_params: {
style: {
name: 'uniform',
params: {
value: decodeColor(color),
saturation: 0,
lightness: 0,
}
},
carbonLightness: carbonLightness // required parameter
}
});
const GColors2 = ill_color('#947c7c', 0.8);
/* from David Goodsell style
in his illustrate software
HETATM-H-------- 0,9999, 1.1,1.1,1.1, 0.0
HETATMH--------- 0,9999, 1.0,1.0,1.0, 0.0
ATOM -H-------- 0,9999, 1.0,1.0,1.0, 0.0
ATOM H--------- 0,9999, 1.0,1.0,1.0, 0.0
HETATM-----HOH-- 0,9999, 1.0,1.0,0.0, 0.0
ATOM -OD--ASP A 0,9999 1.00, 0.20, 0.20, 1.6
ATOM -OE--GLU A 0,9999 1.00, 0.20, 0.20, 1.6
ATOM -NZ--LYS A 0,9999 0.10, 0.70, 1.00, 1.6
ATOM -NH--ARG A 0,9999 0.10, 0.70, 1.00, 1.6
ATOM -NE--ARG A 0,9999 0.10, 0.70, 1.00, 1.6
ATOM -ND--HIS A 0,9999 0.10, 0.70, 1.00, 1.6
ATOM -NE--HIS A 0,9999 0.10, 0.70, 1.00, 1.6
ATOM -N------ A 0,9999 0.80, 0.90, 1.00, 1.5
ATOM -O------ A 0,9999 1.00, 0.80, 0.80, 1.5
ATOM -C------ A 0,9999 1.00, 1.00, 1.00, 1.6
ATOM -S------ A 0,9999 1.00, 0.90, 0.50, 1.8
HETATM-C------ - 0,9999 0.60, 0.90, 0.60, 1.5
HETATM-------- - 0,9999 0.40, 0.90, 0.40, 1.5
*/
const GColors3 = {
schema: 'all_atomic', // or maybe just 'atom'
category_name: 'atom_site',
field_name: 'type_symbol',
palette: {
kind: 'categorical',
// missing_color: ...
colors: {
'C': '#FFFFFF',
'N': '#CCE6FF',
'O': '#FFCCCC',
'S': '#FFE680',
}
}
} as unknown as MVSNodeParams<'color_from_source'>;
const audioPathBase = 'https://raw.githubusercontent.com/molstar/molstar/master';
// For local debug
// const audioPathBase = '';
const _Audio1 = audioPathBase + '/examples/audio/AudioMOM1_A.mp3';
const _Audio2 = audioPathBase + '/examples/audio/AudioMOM1_B.mp3';
const _Audio3 = audioPathBase + '/examples/audio/AudioMOM1_C.mp3';
const _Audio4 = audioPathBase + '/examples/audio/AudioMOM1_D.mp3';
const q = (expr: string, lang = 'pymol') =>
`!query=${encodeURIComponent(expr)}&lang=${lang}&action=highlight,focus`;
const description_intro = `
# Molecule of the Month: Myoglobin
A story based on the orginal [first Molecule of the Month](https://pdb101.rcsb.org/motm/1) made by David Goodsell in January 2000.
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio1}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
Myoglobin was the first protein to have its atomic structure determined, revealing how it stores oxygen in muscle cells.
---
## The First Protein Structure
Any discussion of protein structure must necessarily begin with **myoglobin**, because it is where the science of protein structure began. After years of arduous work, *John Kendrew* and his coworkers determined the atomic structure of myoglobin, laying the foundation for an era of biological understanding.
You can take a close look at this protein structure yourself, in **PDB entry [1mbn](https://www.rcsb.org/structure/1MBN)**. You will be amazed—just like the world was in 1960—at the beautiful intricacy of this protein.
---
## Myoglobin and Muscles
[Myoglobin](!query%3Dchain%20A%26lang%3Dpymol%26action%3Dhighlight%2Cfocus) is a **small, bright red protein**. It is very common in muscle cells and gives meat much of its red color. Its job is to **store oxygen**, for use when muscles are hard at work.
To do this, it uses a special chemical tool to capture slippery oxygen molecules: a **[heme group](!query%3Dresn%20HEM%26lang%3Dpymol%26action%3Dhighlight%2Cfocus)**. Heme is a disk-shaped molecule with a hole in the center that is perfect for holding an iron ion. The iron then forms a strong interaction with the **[oxygen molecule](!query%3Dresn%20OH%26lang%3Dpymol%26action%3Dhighlight%2Cfocus)**. As you can see in the structure, the heme group is held tightly in a deep pocket on one side of the protein.
---
## Visualizing Protein Structure
When the structure of myoglobin was solved, it posed a great challenge. The structure is so complex that **new methods** needed to be developed to display and understand it.
- *John Kendrew* used a huge wire model to build the structure based on the experimental electron density.
- Then, the artist *Irving Geis* was employed to create a picture of myoglobin for a prominent article in *Scientific American*.
- Computer graphics were still many years in the future, so he created this illustration entirely by hand—one atom at a time.
You can learn more about the work of Irving Geis at the **[Geis Archive on PDB-101](https://pdb101.rcsb.org/learn/GeisArchive)**.
![Alt Text](https://cdn.rcsb.org/pdb101/motm/1/1-Myoglobin-geis-0218-myoglobin.png)
*Illustration of myoglobin by Irving Geis. You can learn more about this painting at the Geis Archive on PDB-101.
Used with permission from the Howard Hughes Medical Institute, Copyright 2015.*
`;
const query1 = MS.struct.generator.atomGroups({
'entity-test': MS.core.rel.eq([
MS.struct.atomProperty.core.modelEntryId(),
'1MBN'
])
});
const firstEntity1 = q(formatMolScript(query1), 'mol-script');
const query2 = MS.struct.generator.atomGroups({
'entity-test': MS.core.rel.eq([
MS.struct.atomProperty.core.modelEntryId(),
'1PMB'
])
});
const firstEntity2 = q(formatMolScript(query2), 'mol-script');
const query3 = MS.struct.generator.atomGroups({
'entity-test': MS.core.rel.eq([
MS.struct.atomProperty.core.modelEntryId(),
'1MBN'
]),
'residue-test': MS.core.set.has([
MS.set(12, 140, 87),
MS.struct.atomProperty.macromolecular.auth_seq_id()
])
});
const charged_residues = q(formatMolScript(query3), 'mol-script');
const description_p1 = `
# Myoglobin and Whales
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio2}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
If you look at John Kendrew's PDB file, you'll notice that the myoglobin he used was taken
from sperm whale muscles. Whales and dolphin have a great need for myoglobin, so that they can
store extra oxygen for use in their deep undersea dives. Typically, they have about 30
times more than in animals that live on land. A recent study revealed that a few special
modifications are needed to make this possible.
Comparing [whale myoglobin](${firstEntity1})
(PDB entry [1mbn](https://www.rcsb.org/structure/1mbn)) with
[pig myoglobin](${firstEntity2})
(PDB entry [1pmb](https://www.rcsb.org/structure/1pmb)), we find that there are
several mutations that add [extra positively-charged
amino acids](${charged_residues}) to the surface. Marine animals typically have these extra charges on
the surface of their myoglobin
to help repel neighboring molecules and prevent aggregation when myoglobin is at
high concentrations.
`;
const description_p2 = `
# Oxygen Bound to Myoglobin
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio3}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
A later structure of myoglobin, PDB entry [1mbo](https://www.rcsb.org/structure/1mbo),
shows that [oxygen](${q('index 1276+1277')}) binds to
the [iron](${q('index 1275')}) atom deep inside the protein.
So how does it get in and out? The
answer is that the structure in the PDB is only one snapshot of the
protein, caught when it is in a tightly-closed form. In reality,
myoglobin (and all other proteins) is constantly in motion, performing
small flexing and breathing motions (illustrated here by PDB entry [1myf](https://www.rcsb.org/structure/1myf)). So, temporary openings constantly
appear and disappear, allowing oxygen in and out.
`;
const description_p3 = `
# Molecule of the Month: Myoglobin
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio1}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
The atomic structure of myoglobin revealed many of the basic principles
of protein structure and stability. For instance, the structure showed
that when the protein chain folds into a globular structure,
[carbon-rich amino acids](${q('resn ALA+VAL+LEU+ILE+MET+PHE+TRP+PRO')}) are sheltered
inside and charged amino acids [positively](${q('resn LYS+ARG+HIS')})
and [negatively](${q('resn GLU+ASP')}) are most often found on the surface,
occasionally forming [salt bridges](${q('chain A and resi 44+47+77+18')})
that pair two opposite charges (shown here with circles).
To explore some of these principles, eplxore freely in the interactive view.
# Topics for Further Discussion
You can use the sequence comparison tool to align the sequences of different
myoglobins, looking for mutations. For instance, [here is the alignment of whale and pig myoglobin](https://www.rcsb.org/alignment?request-body=eJyljrsOwjAMRf%2FFcxgqxNKN7kXsqKpC4pZIeZTEVamq%2FDtOGRAzm%2BPje3I3eM4YV6g3CBOZ4FMZI9IcfZ%2BQoVfYa0kS6kHahFmACp7wReXQBY1QwyRNXExCEOCQHkEX5qUrjNxBWjN64GSiOCtWI%2F9y2wA9xbU3fA1V21w4ndCiKjWKQKbVfeiZ0R3HUmhfVIKz%2Bvs8HXMWv75r2%2Fzn61gJg7F71y6%2FARZEZL8%3D&response-body=eJzlU11vmzAU%2FS88Q2RsjKFv7iCA7GRJQFRVVUU0uClSQjo%2BtkVV%2FvuuyUdTadMeurcJIXF97j33HN%2FLm1HVzzvj5s3o%2B6o0bgzlUaZoyaynwvUshznPll8UpVVgRUr1hAkrmWEabVd0fQv5X75OZjLMQuNgGlvVFZqq2FTreqvqbrndlQqSXouq%2BVG1CgqvMNW97HTLbmsNp5qiUW2%2F6YD44Q16NP2q6%2BFoCKGm2S8HkfbkdqpFqI1addWuHpq2%2B%2B0R5QA9qfWyVd%2BGA9uE2vI9pORwMD%2FyzSa3n%2BN7NN%2FlLi8eB92NWgOl9vDwF1YdVnWpfho3yDQ2ql53L0f%2BR%2FMTtaCta4q6fd4126I7a7FNdHp%2B%2BwUd0chxCXY9xjA2LTRiNkWMONT2TDSimDi%2Bz1yHQDaAmGCbeTbxXR25rodsG%2FvHiCGGEHE9D2vukUcpdgnBlEGAEfU9RrFPdKbDqOMT2x8yLYpH1MWOQxzP9U3CRi5lBCGKoKlFR77j2Rg5iNkgVw%2Bg326LZq%2BH165257X5Xmx62EFo68I97F%2F1PsK99apeKasqYUpVtzf0QlwyXeeSuZikwUfQ9y5gNrGGRoYefw1jr5fQtSp7tdQb3w73rxH9G2xUeUa1MI3Aq2XDEHUpzOxUoKPV7rtqirXSqQjmgdDjcctO0v%2B4ZJ%2FYsQs5lOYyDaPwbi5zGed3XOQhD3IexdE8SGSykGORxrMwk6EYB4uxiKXIQh5OBE%2FDQAoRR3mWy4zLiCcQiiiOAZZiJvk8jXkmYpHMEnEvw3GShjxJYuiTLuJZFIwjHvB5xCdTwWUoxwsRJJyLexHK6H4eDdP453YjmQYnu9P8LrqyG%2BZHu9HFrhjsgs9ru9OT3ejabvbBbn5ld57LeSo%2B2E1PdqfB5Gx3rO3%2BH5t9%2BAU7Kf5z&encoded=true) used to create the illustration in this column.
PDB entry [2jho](https://www.rcsb.org/structure/2jho) includes myoglobin poisoned by cyanide. Take a look and you'll see that the cyanide blocks the binding site for oxygen.
# References
- 1mbn: J. C. Kendrew, R. E. Dickerson, B. E. Strandberg, R. G. Hart, D. R. Davies, D. C. Phillips & V. C. Shore (1960) Structure of Myoglobin. Nature 185, 422-427.
- J. C. Kendrew (1961) The three-dimensional structure of a protein molecule. Scientific American 205(6), 96-110.
- 1mbo: S. E. Phillips (1980) Structure and refinement of oxymyoglobin at 1.6 A resolution. Journal of Molecular Biology 142, 531-554.
- 1pmb: S. J. Smerdon, T. J. Oldfield, E. J. Dodson, G. G. Dodson, R. E. Hubbard & A. J. Wilkinson (1990) Determination of the crystal structure of recombinant pig myoglobin by molecular replacement and its refinement. Acta Crystallographica B, 46, 370-377.
- 1myf: Osapay K, Theriault Y, Wright PE, Case DA. Solution structure of carbonmonoxy myoglobin determined from nuclear magnetic resonance distance and chemical shift constraints. J Mol Biol. 1994;244(2):183-197. doi:10.1006/jmbi.1994.1718
- S. Mirceta, A. V. Signore, J. M. Burns, A. R. Cossins, K. L. Campbell & M. Berenbrink (2013) Evolution of mammalian diving capacity traced by myoglobin net surface charge. Science 340, 1234192.
`;
const Steps = [
{
header: 'Molecule of the Month: Myoglobin',
key: 'intro',
description: description_intro,
linger_duration_ms: 45000,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
// no outline here
builder.canvas({ custom: { molstar_postprocessing: { enable_outline: false } } });
const _1mbn = structure(builder, '1MBN');
_1mbn.component({ selector: 'ligand' })
.representation({ ref: 'ligand', type: 'ball_and_stick' })
.color({ color: 'orange' });
// FE and O should be spacefill
_1mbn.component({ selector: { auth_seq_id: 155, label_atom_id: 'F' } })
.representation({ type: 'spacefill' })
.color({ color: 'yellow' });
_1mbn.component({ selector: { auth_seq_id: 154 } })
.representation({ type: 'spacefill' })
.color({ color: 'blue' });
_1mbn.component({ selector: { auth_seq_id: 154 } })
.representation({ type: 'spacefill' })
.color({ color: 'blue' });
const chA = _1mbn.component({ selector: { label_asym_id: 'A' } });
chA.representation({ type: 'surface', surface_type: 'gaussian' })
.color({ color: '#ff0303' })
.opacity({ ref: 'surfopa', opacity: 0.0 });
chA.representation({ type: 'line' })
.color({ custom: { molstar_color_theme_name: 'element-symbol' } })
.opacity({ ref: 'lineopa', opacity: 0.0 });
chA.representation({ type: 'cartoon' })
.color({ custom: { molstar_color_theme_name: 'secondary-structure' } });
// whale
_1mbn.component({ selector: { label_asym_id: 'A' } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.colorFromSource({
schema: 'all_atomic',
category_name: 'atom_site',
field_name: 'type_symbol',
palette: {
kind: 'categorical',
colors: {
'C': '#FFFFFF',
'N': '#CCE6FF',
'O': '#FFCCCC',
'S': '#FFE680',
}
}
}).opacity({ ref: 'cpkopa1', opacity: 0.0 });
_1mbn.component({ selector: { auth_seq_id: 155 } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.color({ custom: GColors2 }).opacity({ ref: 'cpkopa2', opacity: 0.0 });
const prims = _1mbn.primitives({
ref: 'prims',
label_opacity: 1,
label_background_color: 'grey',
custom: {
molstar_markdown_commands: {
// 'apply-snapshot': 'interlude',
'play-audio': _Audio1,
}
}
});
prims.label({
text: 'Start Comments',
position: [13.5, 45.1, 7.7],
label_size: 5
});
addNextButton(builder, 'whale', [13.5, 0, 7.7]);
// doesnt work for first slide, but work afterward
builder.extendRootCustomState({
molstar_on_load_markdown_commands: {
'play-audio': _Audio1,
}
});
const anim = builder.animation(
{
custom: {
molstar_trackball: {
name: 'spin',
params: { speed: -0.05 },
}
}
}
);
anim.interpolate({
kind: 'scalar',
target_ref: 'lineopa',
duration_ms: 2000,
start_ms: 0,
property: 'opacity',
start: 0.0,
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'ligand',
start_ms: 22000,
duration_ms: 10000,
frequency: 6,
alternate_direction: true,
property: ['custom', 'molstar_representation_params', 'emissive'],
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'cpkopa1',
duration_ms: 5000,
start_ms: 40000,
property: 'opacity',
start: 0.0,
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'cpkopa2',
duration_ms: 5000,
start_ms: 40000,
property: 'opacity',
start: 0.0,
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'next',
duration_ms: 2000,
start_ms: 43000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
return builder;
},
camera: {
position: [13.5, 21.1, 73.1],
target: [13.5, 21.1, 7.7],
up: [0, 1, 0],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Myoglobin and Whales',
key: 'whale',
description: description_p1,
linger_duration_ms: 41000,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
const _1mbn = structure(builder, '1mbn').transform({ ref: 'whalex', translation: [-30, 0, 0] });
// whale
_1mbn.component({ selector: { label_asym_id: 'A' } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.colorFromSource({
schema: 'all_atomic', // or maybe just 'atom'
category_name: 'atom_site',
field_name: 'type_symbol',
palette: {
kind: 'categorical',
colors: {
'C': '#FFFFFF',
'N': '#CCE6FF',
'O': '#FFCCCC',
'S': '#FFE680',
}
}
});
_1mbn.component({ selector: { auth_seq_id: 155 } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.color({ custom: GColors2 });
_1mbn.primitives({
ref: 'prims',
label_opacity: 1,
label_attachment: 'top-center',
label_show_tether: true,
label_tether_length: 1.0,
})
.label({
text: 'whale',
position: { label_asym_id: 'A', auth_seq_id: 8 },
label_size: 10
});
_1mbn.primitives({
ref: 'startres',
label_opacity: 0,
})
.label({
text: '★', label_offset: 4,
position: { label_asym_id: 'A', auth_seq_id: 12, atom_id: 96 }, label_size: 5
})
.label({
text: '★', label_offset: 4,
position: { label_asym_id: 'A', auth_seq_id: 140, auth_atom_id: 'NZ' }, label_size: 5
})
.label({
text: '★', label_offset: 4,
position: { label_asym_id: 'A', auth_seq_id: 87, auth_atom_id: 'NZ' }, label_size: 5
});
// the following doesnt work
const seld = _1mbn.component({
selector: [
{ label_asym_id: 'A', auth_seq_id: 12 },
{ label_asym_id: 'A', auth_seq_id: 140 },
{ label_asym_id: 'A', auth_seq_id: 87 }
]
});
seld.representation({ ref: 'scharged', type: 'surface', surface_type: 'gaussian', custom: { molstar_representation_params: { emissive: 0.0, ignoreLight: true } } })
.colorFromSource(GColors3);
// pig
const _1pmb = structure(builder, '1pmb').transform({ ref: 'pig', matrix: align });
_1pmb.component({ selector: { label_asym_id: 'A' } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.colorFromSource(GColors3);
_1pmb.component({ selector: { label_asym_id: 'C', auth_seq_id: 154 } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.color({ custom: GColors2 });
_1pmb.primitives({
ref: 'labelpig',
label_opacity: 1,
label_attachment: 'top-center',
label_show_tether: true,
label_tether_length: 1.0,
})
.label({
text: 'pig',
position: { label_asym_id: 'A', auth_seq_id: 8 },
label_size: 10
});
builder.extendRootCustomState({
molstar_on_load_markdown_commands: {
'play-audio': _Audio2,
}
});
const anim = builder.animation(
{
custom: {
molstar_trackball: {
name: 'spin',
params: { speed: -0.05 },
}
}
});
anim.interpolate({
kind: 'vec3',
target_ref: 'whalex',
duration_ms: 10000,
start_ms: 16000,
property: 'translation',
start: [-30, 0, 0],
end: [-60, 0, 0],
});
anim.interpolate({
kind: 'scalar',
target_ref: 'startres',
duration_ms: 1000,
start_ms: 20000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
// pig appear at 18s
anim.interpolate({
kind: 'transform_matrix',
target_ref: 'pig',
duration_ms: 5000,
start_ms: 18000,
property: 'matrix',
translation_start: [-82.54880970106205, 37.49099778180445, -6.133850309914719],
translation_end: [-52.54880970106205, 37.49099778180445, -6.133850309914719],
});
anim.interpolate({
kind: 'scalar',
target_ref: 'labelpig',
duration_ms: 2000,
start_ms: 18000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
addNextButton(builder, 'oxygen', [-18.9, 10, 7.3]);
anim.interpolate({
kind: 'scalar',
target_ref: 'next',
duration_ms: 2000,
start_ms: 38000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'scharged',
start_ms: 20000,
duration_ms: 6000,
frequency: 6,
alternate_direction: true,
property: ['custom', 'molstar_representation_params', 'emissive'],
start: 0.0,
end: 1.0,
});
return builder;
},
camera: {
position: [-14.6, 116.1, 66.5],
target: [-18.9, 21.1, 7.3],
up: [-0.0, 0.5, -0.8],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Oxygen Bound',
key: 'oxygen',
description: description_p2,
linger_duration_ms: 18000,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
// NMR 1MYF
// 1A6N unbound
// 1A6M bound
// series 2G0R
const _1mbo = structure(builder, '1mbo')
.transform({ matrix: alignmbo });
const _1myf = builder
.download({ url: pdbUrl('1myf') })
.parse({ format: 'bcif' })
.modelStructure({ ref: '1myf' });
const red1 = '#d3a4a6';
const red2 = '#d75354';
const blue1 = '#02d1d1';
_1myf.component({ selector: { label_asym_id: 'A' } })
.transform({ translation: [0, 0, 0] })
.representation({ type: 'spacefill' })
.color({ color: red1 })
.opacity({ ref: 'spo', opacity: 1.0 });
// OXYY
// should animate in-out in loop
_1mbo.component({ selector: { label_asym_id: 'C', auth_seq_id: 155 } })
.representation({ type: 'spacefill' })
.color({
custom: {
molstar_color_theme_name: 'element-symbol',
molstar_color_theme_params: {
carbonColor: {
name: 'uniform',
params: { value: decodeColor(red2) }
},
}
}
});
_1myf.component({ selector: { label_asym_id: 'A' } })
.representation({ type: 'backbone' })
.color({ color: red1 });
_1mbo.component({ selector: { label_asym_id: 'D', auth_seq_id: 555 } })
.representation({
ref: 'oxy', type: 'spacefill', custom: {
molstar_representation_params: {
emissive: 0.0
}
}
})
.color({ color: blue1 });
_1mbo.component({ selector: { label_asym_id: 'D', auth_seq_id: 555 } })
.transform({ ref: 'oxyy', translation: [0, 0, 0] })
.representation({ type: 'spacefill' })
.color({ color: blue1 })
.opacity({ ref: 'oxop', opacity: 0.0 });
builder.extendRootCustomState({
molstar_on_load_markdown_commands: {
'play-audio': _Audio3,
}
});
const anim = builder.animation(
{
custom: {
molstar_trackball: {
name: 'spin',
params: { speed: -0.05 },
}
}
});
anim.interpolate({
kind: 'scalar',
target_ref: 'spo',
duration_ms: 5000,
start_ms: 0,
property: 'opacity',
start: 1.0,
end: 0.05,
});
anim.interpolate({
kind: 'scalar',
target_ref: '1myf',
start_ms: 11000,
duration_ms: 10000,
frequency: 4,
alternate_direction: true,
property: 'model_index',
discrete: true,
start: 0,
end: 11,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'oxy',
start_ms: 3000,
duration_ms: 10000,
frequency: 7,
alternate_direction: true,
property: ['custom', 'molstar_representation_params', 'emissive'],
end: 1.0,
});
anim.interpolate({
kind: 'vec3',
target_ref: 'oxyy',
duration_ms: 5000,
start_ms: 16000,
property: 'translation',
frequency: 4,
alternate_direction: false,
start: [5, -5, -20],
end: [0, 0, 0],
noise_magnitude: 1,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'oxop',
duration_ms: 1000,
start_ms: 15000,
property: 'opacity',
start: 0.0,
end: 1.0,
});
addNextButton(builder, 'end', [0, -25, 0.0]);
anim.interpolate({
kind: 'scalar',
target_ref: 'next',
duration_ms: 2000,
start_ms: 18000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
return builder;
},
camera: {
position: [-2.2, 0.7, -78.5],
target: [-0.1, 0.7, 0.6],
up: [0, 1, 0],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Conclusion',
key: 'end',
description: description_p3,
linger_duration_ms: 20000,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
const _1mbn = structure(builder, '1mbn');
// resn ALA+VAL+LEU+ILE+MET+PHE+TRP+PRO
const carb = ['ALA', 'VAL', 'LEU', 'ILE', 'MET', 'PHE', 'TRP', 'PRO'].map(amk => ({ label_comp_id: amk }));
// resn LYS+ARG+HIS+ASP+GLU
const chargedp = ['LYS', 'ARG', 'HIS'].map(amk => ({ label_comp_id: amk }));
const chargedn = ['ASP', 'GLU'].map(amk => ({ label_comp_id: amk }));
// salt bridge
// ASP44-OD1-356-LYS47-NZ-388
// LYS77-NZ-613-GLU18-OE1-149
// use primitve distance_measurement
// and ellipse or ellipsoid with transparancy
_1mbn.primitives({ ref: 'dist', label_opacity: 0.0 })
.distance({
start: { label_asym_id: 'A', auth_seq_id: 44, atom_id: 356 },
end: { label_asym_id: 'A', auth_seq_id: 47, atom_id: 388 },
radius: 0.1, dash_length: 0.1,
label_size: 2
})
.distance({
start: { label_asym_id: 'A', auth_seq_id: 77, atom_id: 613 },
end: { label_asym_id: 'A', auth_seq_id: 18, atom_id: 149 },
radius: 0.1, dash_length: 0.1,
label_size: 2
});
// 44 OD1 22.300 33.300 -6.200
// 47 NZ 23.200 32.000 -8.400
const r44 = Vec3.create(22.300, 33.300, -6.200);
const r47 = Vec3.create(23.200, 32.000, -8.400);
getEllipse(builder, r44, r47, 'salt1');
// 18 OE1 16.600 22.500 20.500
// 77 NZ 14.100 23.600 22.200
const r18 = Vec3.create(16.600, 22.500, 20.500);
const r77 = Vec3.create(14.100, 23.600, 22.200);
getEllipse(builder, r18, r77, 'salt2');
const a = _1mbn.component({ selector: carb });
a.representation({ type: 'ball_and_stick' })
.color({ color: '#bec0f2' })
.opacity({ ref: 'carb', opacity: 1.0 });
const b = _1mbn.component({ selector: chargedp });
b.representation({ type: 'ball_and_stick' })
.color({ custom: ill_color('blue', 3.0) })
.opacity({ ref: 'chargedp', opacity: 1.0 });
const c = _1mbn.component({ selector: chargedn });
c.representation({ type: 'ball_and_stick' })
.color({ custom: ill_color('red', 3.0) })
.opacity({ ref: 'chargedn', opacity: 1.0 });
_1mbn.component({ selector: { label_asym_id: 'A' } })
.representation({ type: 'backbone' })
.color({ color: '#919191' });
_1mbn.component({ selector: 'ligand' })
.representation({
ref: 'ligand', type: 'ball_and_stick',
custom: {
molstar_representation_params: {
emissive: 0.0
}
}
})
.color({ color: 'orange' });
builder.extendRootCustomState({
molstar_on_load_markdown_commands: {
'play-audio': _Audio4,
}
});
const anim = builder.animation({});
anim.interpolate({
kind: 'scalar',
target_ref: 'carb',
duration_ms: 2000,
start_ms: 8000,
frequency: 2,
alternate_direction: true,
property: 'opacity',
start: 0.0,
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'chargedp',
duration_ms: 1000,
start_ms: 10000,
property: 'opacity',
start: 0.0,
end: 1.0,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'chargedn',
duration_ms: 1000,
start_ms: 10000,
property: 'opacity',
start: 0.0,
end: 1.0,
});
// show salt bridge
anim.interpolate({
kind: 'scalar',
target_ref: 'salt1',
duration_ms: 1000,
start_ms: 11000,
property: 'opacity',
start: 0.0,
end: 0.3,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'salt2',
duration_ms: 1000,
start_ms: 11000,
property: 'opacity',
start: 0.0,
end: 0.3,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'dist',
duration_ms: 1000,
start_ms: 11000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
addNextButton(builder, 'intro', [13.5, -10.0, 7.7]);
anim.interpolate({
kind: 'scalar',
target_ref: 'next',
duration_ms: 2000,
start_ms: 20000,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
return builder;
},
camera: {
position: [16.0, 47.2, 67.8],
target: [13.6, 21.1, 7.6],
up: [0.1, 0.9, -0.4],
} satisfies MVSNodeParams<'camera'>,
},
];
function addNextButton(builder: any, snapshotKey: string, position: [number, number, number]) {
builder.primitives({
ref: 'next',
tooltip: 'Click for next part',
label_opacity: 0,
label_background_color: 'grey',
snapshot_key: snapshotKey
})
.label({
ref: 'next_label',
position: position,
text: 'Click me to go next',
label_color: 'white',
label_size: 5
});
}
function structure(builder: Root, id: string): MVSStructure {
return builder
.download({ url: pdbUrl(id) })
.parse({ format: 'bcif' })
.modelStructure();
}
function getEllipse(builder: Root, pos1: Vec3, pos2: Vec3, ref: string) {
const center = Vec3.add(Vec3(), pos1, pos2);
Vec3.scale(center, center, 0.5);
const major_axis = Vec3.sub(Vec3(), pos2, pos1);
const z_axis = Vec3.create(0, 0, 1);
// cross to get minor
const minor_axis = Vec3.cross(Vec3(), major_axis, z_axis);
return builder.primitives({ ref: ref, opacity: 0.33 }).ellipsoid({
center: center as any,
major_axis: major_axis as any,
minor_axis: minor_axis as any,
radius: [5.0, 3.0, 3.0],
color: '#cccccc',
});
}
function pdbUrl(id: string) {
return `https://www.ebi.ac.uk/pdbe/entry-files/download/${id.toLowerCase()}.bcif`;
}
export function buildStory(): MVSData_States {
const snapshots = Steps.map((s, i) => {
const builder = s.state();
if (s.camera) builder.camera(s.camera);
const description = i > 0 ? `${s.description}\n\n[Go to start](#intro)` : s.description;
return builder.getSnapshot({
title: s.header,
key: s.key,
description,
description_format: 'markdown',
linger_duration_ms: s.linger_duration_ms ?? 500,
transition_duration_ms: s.transition_duration_ms ?? 1000,
});
});
return {
kind: 'multiple',
snapshots,
metadata: {
title: 'RCSB Molecule of the Month 1',
version: '1.0',
timestamp: new Date().toISOString(),
}
};
}

View File

@@ -6,7 +6,10 @@
*/
import { Camera } from '../../mol-canvas3d/camera';
import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
import { CameraFogParams, Canvas3DParams, Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
import { TrackballControlsParams } from '../../mol-canvas3d/controls/trackball';
import { BloomParams } from '../../mol-canvas3d/passes/bloom';
import { DofParams } from '../../mol-canvas3d/passes/dof';
import { OutlineParams } from '../../mol-canvas3d/passes/outline';
import { ShadowParams } from '../../mol-canvas3d/passes/shadow';
import { SsaoParams } from '../../mol-canvas3d/passes/ssao';
@@ -22,6 +25,7 @@ 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';
@@ -119,42 +123,93 @@ 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: {} };
}
return fallback;
}
/** 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;
const outline = !!canvasNode?.custom?.molstar_enable_outline;
const outlineParams = canvasNode?.custom?.molstar_outline_params;
const molstar_postprocessing = canvasNode?.custom?.molstar_postprocessing;
const shadow = !!canvasNode?.custom?.molstar_enable_shadow;
const shadowParams = canvasNode?.custom?.molstar_shadow_params;
const outline = molstar_postprocessing?.enable_outline;
const outlineParams = molstar_postprocessing?.outline_params;
const occlusion = !!canvasNode?.custom?.molstar_enable_ssao;
const ssaoParams = canvasNode?.custom?.molstar_ssao_params;
const shadow = molstar_postprocessing?.enable_shadow;
const shadowParams = molstar_postprocessing?.shadow_params;
const occlusion = molstar_postprocessing?.enable_ssao;
const occlusionParams = molstar_postprocessing?.ssao_params;
const fog = molstar_postprocessing?.enable_fog;
const fogParams = molstar_postprocessing?.fog_params;
const dof = molstar_postprocessing?.enable_depth_of_field;
const dofParams = molstar_postprocessing?.depth_of_field_params;
const bloom = molstar_postprocessing?.enable_bloom;
const bloomParams = molstar_postprocessing?.bloom_params;
const trackballAnimation = animationNode?.custom?.molstar_trackball;
const trackballAnimationName = trackballAnimation?.name;
const trackballAnimationParams = trackballAnimation?.params ?? {};
return {
...oldCanvasProps,
postprocessing: {
...oldCanvasProps.postprocessing,
outline: outline
? { name: 'on', params: { ...ParamDefinition.getDefaultValues(OutlineParams), ...outlineParams } }
: oldCanvasProps.postprocessing.outline,
shadow: shadow
? { name: 'on', params: { ...ParamDefinition.getDefaultValues(ShadowParams), ...shadowParams } }
: oldCanvasProps.postprocessing.shadow,
occlusion: occlusion
? { name: 'on', params: { ...ParamDefinition.getDefaultValues(SsaoParams), ...ssaoParams } }
: oldCanvasProps.postprocessing.occlusion,
outline: optionalParams(outline, outlineParams, OutlineParams, oldCanvasProps.postprocessing.outline),
shadow: optionalParams(shadow, shadowParams, ShadowParams, oldCanvasProps.postprocessing.shadow),
occlusion: optionalParams(occlusion, occlusionParams, SsaoParams, oldCanvasProps.postprocessing.occlusion),
dof: optionalParams(dof, dofParams, DofParams, oldCanvasProps.postprocessing.dof),
bloom: optionalParams(bloom, bloomParams, BloomParams, oldCanvasProps.postprocessing.bloom),
},
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
}
}
}
: {}
),
}
};
}
export function resetCanvasProps(plugin: PluginContext) {
const old = plugin.canvas3d?.props;
plugin.canvas3d?.setProps({
...old,
postprocessing: {
...old,
outline: DefaultCanvas3DParams.postprocessing.outline,
shadow: DefaultCanvas3DParams.postprocessing.shadow,
occlusion: DefaultCanvas3DParams.postprocessing.occlusion,
dof: DefaultCanvas3DParams.postprocessing.dof,
bloom: DefaultCanvas3DParams.postprocessing.bloom,
},
cameraFog: DefaultCanvas3DParams.cameraFog,
trackball: {
...old?.trackball,
animate: { name: 'off', params: {} },
}
});
}

View File

@@ -241,7 +241,7 @@ function makeNumericPaletteScale(props: MVSContinuousPaletteProps | MVSDiscreteP
}
}
function makeContinuousPaletteCheckpoints(props: MVSContinuousPaletteProps) {
export function makeContinuousPaletteCheckpoints(props: MVSContinuousPaletteProps) {
if (props.colors.colors.every(x => Array.isArray(x))) {
// Explicit checkpoints
const sorted = props.colors.colors.sort((a, b) => a[1] - b[1]);

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

@@ -39,8 +39,9 @@ import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { capitalize } from '../../../mol-util/string';
import { rowsToExpression, rowToExpression } from '../helpers/selections';
import { collectMVSReferences, decodeColor, isDefined } from '../helpers/utils';
import { MolstarNode, MolstarSubtree } from '../tree/molstar/molstar-tree';
import { MVSNode } from '../tree/mvs/mvs-tree';
import { addParamDefaults } from '../tree/generic/params-schema';
import { MolstarNode, MolstarNodeParams, MolstarSubtree } from '../tree/molstar/molstar-tree';
import { MVSNode, MVSTreeSchema } from '../tree/mvs/mvs-tree';
import { isComponentExpression, isPrimitiveComponentExpressions, isVector3, PrimitivePositionT } from '../tree/mvs/param-types';
import { MVSTransform } from './annotation-structure-component';
@@ -97,6 +98,16 @@ export const MVSDownloadPrimitiveData = MVSTransform({
},
});
/* Cannot use MolstarSubtree<'primitives'>> because information about type of children would be lost and cause TypeScript errors in dependent code */
interface PrimitivesSubtree {
kind: 'primitives',
params: MolstarNodeParams<'primitives'>,
children?: {
kind: 'primitive',
params: MolstarNodeParams<'primitive'>,
}[],
}
export type MVSInlinePrimitiveData = typeof MVSInlinePrimitiveData
export const MVSInlinePrimitiveData = MVSTransform({
name: 'mvs-inline-primitive-data',
@@ -104,7 +115,10 @@ export const MVSInlinePrimitiveData = MVSTransform({
from: [SO.Root, SO.Molecule.Structure],
to: MVSPrimitivesData,
params: {
node: PD.Value<MolstarSubtree<'primitives'>>(undefined as any, { isHidden: true }),
node: PD.Value<PrimitivesSubtree>({
kind: 'primitives',
params: addParamDefaults(MVSTreeSchema.nodes.primitives.params, {}),
}, { isHidden: true }),
},
})({
apply({ a, params }) {
@@ -135,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') {
@@ -145,9 +160,9 @@ export const MVSBuildPrimitiveShape = MVSTransform({
label,
data: context,
params: {
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1 }),
...customMeshParams,
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1, ...customMeshParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveMesh(data, prev?.geometry),
geometryUtils: Mesh.Utils,
@@ -169,9 +184,10 @@ export const MVSBuildPrimitiveShape = MVSTransform({
tetherLength: options?.label_tether_length ?? 1,
background: isDefined(bgColor),
backgroundColor: isDefined(bgColor) ? decodeColor(bgColor) : undefined,
...customLabelParams,
}),
...customLabelParams,
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, props, prev: any) => buildPrimitiveLabels(data, prev?.geometry, props),
geometryUtils: Text.Utils,
@@ -184,9 +200,9 @@ export const MVSBuildPrimitiveShape = MVSTransform({
label,
data: context,
params: {
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1 }),
...customLineParams,
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1, ...customLineParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveLines(data, prev?.geometry),
geometryUtils: Lines.Utils,
@@ -215,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 });
}
@@ -229,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 });
}
@@ -240,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

@@ -0,0 +1,605 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Ludovic Autin <ludovic.autin@gmail.com>
*/
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
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 { produce } from '../../../mol-util/produce';
import { makeContinuousPaletteCheckpoints, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from '../components/annotation-color-theme';
import { palettePropsFromMVSPalette } from '../load-helpers';
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, snapshotIndex: number, snapshotCount: number) {
if (!snapshot.animation) return undefined;
const tree = addDefaults(snapshot.animation, MVSAnimationSchema);
const transitions = tree.children?.filter(child => child.kind === 'interpolate');
if (!transitions?.length) return undefined;
const duration = Math.max(
snapshot.animation.params?.duration_ms ?? 0,
...transitions.map(t => (t.params.start_ms ?? 0) + t.params.duration_ms)
);
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, 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 for snapshot ${snapshotIndex + 1}/${snapshotCount}`, current: i + 1, max: N });
}
}
return { tree, frametimeMs: dt, frames };
}
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = {
'linear': t => t,
'bounce-in': EasingFns.bounceIn,
'bounce-out': EasingFns.bounceOut,
'bounce-in-out': EasingFns.bounceInOut,
'circle-in': EasingFns.circleIn,
'circle-out': EasingFns.circleOut,
'circle-in-out': EasingFns.circleInOut,
'cubic-in': EasingFns.cubicIn,
'cubic-out': EasingFns.cubicOut,
'cubic-in-out': EasingFns.cubicInOut,
'exp-in': EasingFns.expIn,
'exp-out': EasingFns.expOut,
'exp-in-out': EasingFns.expInOut,
'quad-in': EasingFns.quadIn,
'quad-out': EasingFns.quadOut,
'quad-in-out': EasingFns.quadInOut,
'sin-in': EasingFns.sinIn,
'sin-out': EasingFns.sinOut,
'sin-in-out': EasingFns.sinInOut,
};
interface InterpolationCacheEntry {
paletteFn?: (value: number) => Color,
rotation?: { axis: Vec3, angle: number, start: Quat, end: Quat },
}
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}`;
}
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: 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 === '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) {
let v = (t * (frequency || 1));
if (v < 1) return v;
if (!alternate) {
v = (v % 1);
if (v === 0) return 1;
return v;
}
if (Math.abs(v - 1) < EPSILON) return 1;
v = v % 2;
if (v > 1) return 2 - v;
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 startBase = transition.params.start ?? getPreviousScalarEnd(previous) ?? 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 <= 0) return startValue;
else if (time >= 1 - EPSILON && !transition.params.alternate_direction) 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') {
const color = paletteFn(t);
return Color.toHexStyle(color);
}
}
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(),
rotation: Mat4(),
scale: Mat4(),
translation: Mat4(),
pivotNeg: Vec3(),
temp: Mat4(),
};
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 transform = select(target, transition.params.property, offset) ?? Mat4.identity();
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 rotation, translation, scale;
if (time <= 0) {
rotation = startRotation as Mat3;
translation = startTranslation as Vec3;
scale = startScale as Vec3;
} else {
const clampedTime = clamp(time, 0, 1);
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();
Mat4.fromTranslation(TransformState.translation, translation);
Mat4.fromScaling(TransformState.scale, scale);
Mat4.setIdentity(TransformState.rotation);
Mat4.fromMat3(TransformState.rotation, rotation);
Mat4.fromTranslation(TransformState.pivotTranslation, pivot as Vec3);
Mat4.fromTranslation(TransformState.pivotTranslationInv, Vec3.negate(TransformState.pivotNeg, pivot as Vec3));
// translation . pivot . rotation . scale . pivotInv
const result = Mat4();
Mat4.mul(result, TransformState.scale, TransformState.pivotTranslationInv);
Mat4.mul(result, TransformState.rotation, result);
Mat4.mul(result, TransformState.translation, result);
return result;
}
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, discrete);
}
return ret;
}
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end[i], t, noise, discrete);
}
return ret;
}
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, discrete);
}
return ret;
}
return interpolateScalar(start, end, t, noise, discrete);
}
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;
}
const InterpolateVectorsState = {
start: Vec3(),
end: Vec3(),
v: Vec3(),
};
function interpolateVectors(start: number[], end: number[] | undefined, t: number, noise: number, isSpherical: boolean) {
if ((!end || start === end) && !noise) return start;
const ret: number[] = Array.from<number>({ length: start.length }).fill(0.1);
for (let i = 0; i < start.length; i += 3) {
const s = Vec3.fromArray(InterpolateVectorsState.start, start, i);
let v: Vec3;
if (end) {
const e = Vec3.fromArray(InterpolateVectorsState.end, end, i);
v = isSpherical
? Vec3.slerp(InterpolateVectorsState.v, s, e, t)
: Vec3.lerp(InterpolateVectorsState.v, s, e, t);
} else {
v = Vec3.clone(s);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(Vec3Noise, noise);
Vec3.add(v, v, Vec3Noise);
}
Vec3.toArray(v, ret, i);
}
return ret;
}
const Vec3Noise = Vec3();
function interpolateVec3(start: Vec3, end: Vec3 | undefined, t: number, noise: number, isSpherical: boolean) {
if ((!end || start === end) && !noise) return start;
let v: Vec3;
if (end) {
v = isSpherical
? Vec3.slerp(Vec3(), start, end, t)
: Vec3.lerp(Vec3(), start, end, t);
} else {
v = Vec3.clone(start);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(Vec3Noise, noise);
Vec3.add(v, v, Vec3Noise);
}
return v;
}
const RotationState = {
start: Quat(),
end: Quat(),
v: Quat(),
noise: Quat(),
axis: Vec3(),
temp: Mat4(),
};
function interpolateRotation(start: Mat3, end: Mat3 | undefined, t: number, noise: number, cache: InterpolationCacheEntry) {
if ((!end || start === end) && !noise) return start;
if (end) {
if (!cache.rotation) {
cache.rotation = {
...relativeAxisAngle(start, end),
start: Quat.fromMat3(Quat(), start),
end: Quat.fromMat3(Quat(), end),
};
}
const { axis, angle, start: startQ, end: endQ } = cache.rotation;
if (angle < 1e-6) {
// start ≈ end: make a clean spin about the detected (or default) axis
Quat.setAxisAngle(RotationState.v, axis, t * 2 * Math.PI); // Make a full turn
} else {
// Normal case: stick with your existing slerp between start/end
Quat.slerp(RotationState.v, startQ, endQ, t);
}
} else {
Quat.fromMat3(RotationState.v, start);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(RotationState.axis, 1);
Quat.setAxisAngle(RotationState.noise, RotationState.axis, 2 * Math.PI * noise * (Math.random() - 0.5));
Quat.multiply(RotationState.v, RotationState.noise, RotationState.v);
}
Mat4.fromQuat(RotationState.temp, RotationState.v);
return Mat3.fromMat4(Mat3(), RotationState.temp);
}
function select(params: any, path: string | (string | number)[], offset: number) {
if (typeof path === 'string') {
return params?.[path];
}
let f = params;
for (let i = offset; i < path.length; i++) {
if (!f) break;
f = f[path[i]];
}
return f;
}
function assign(params: any, path: string | (string | number)[], value: any, offset: number) {
if (!params) return;
if (typeof path === 'string') {
params[path] = value;
return;
}
let f = params;
for (let i = offset; i < path.length; i++) {
if (!f) break;
if (i === path.length - 1) {
f[path[i]] = value;
} else {
f = f[path[i]];
}
}
}
function makeNodeMap(tree: Tree, map: Map<string, (string | number)[]>, currentPath: (string | number)[]) {
if (tree.ref) {
map.set(tree.ref, [...currentPath]);
}
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;
const params = props.params.palette
? palettePropsFromMVSPalette(props.params.palette)
: palettePropsFromMVSPalette({ kind: 'continuous', colors: [start ?? 'black', end ?? start ?? 'black'] });
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}`);
}
function makePaletteFunctionDiscrete(props: MVSDiscretePaletteProps): (value: number) => Color {
const defaultColor = Color(0x0);
if (props.colors.length === 0) return () => defaultColor;
return (value: number) => {
const x = clamp(value, 0, 1);
for (let i = props.colors.length - 1; i >= 0; i--) {
const { color, fromValue, toValue } = props.colors[i];
if (fromValue <= x && x <= toValue) return color;
}
return defaultColor;
};
}
function makePaletteFunctionContinuous(props: MVSContinuousPaletteProps): (value: number) => Color {
const defaultColor = Color(0x0);
const { colors, checkpoints } = makeContinuousPaletteCheckpoints(props);
if (colors.length === 0) return () => defaultColor;
const underflowColor = props.setUnderflowColor ? props.underflowColor : defaultColor;
const overflowColor = props.setOverflowColor ? props.overflowColor : defaultColor;
return (value: number) => {
const x = clamp(value, 0, 1);
const gteIdx = SortedArray.findPredecessorIndex(checkpoints, x); // Index of the first greater or equal checkpoint
if (gteIdx === 0) {
if (x === checkpoints[0]) return colors[0];
else return underflowColor;
}
if (gteIdx === checkpoints.length) {
return overflowColor;
}
const q = (x - checkpoints[gteIdx - 1]) / (checkpoints[gteIdx] - checkpoints[gteIdx - 1]);
return Color.interpolate(colors[gteIdx - 1], colors[gteIdx], q);
};
}
const RelativeAxisAngleState = {
Rt: Mat3(),
R: Mat3(),
};
function relativeAxisAngle(start: Mat3, end: Mat3): { axis: Vec3, angle: number } {
// R_rel = end * start^T
const R0 = start, R1 = end;
const Rt = Mat3.transpose(RelativeAxisAngleState.Rt, R0);
const R = Mat3.mul(RelativeAxisAngleState.R, R1, Rt);
const tr = R[0] + R[4] + R[8]; // trace
let angle = Math.acos(clamp((tr - 1) * 0.5, -1, 1)); // in [0, π]
const axis = Vec3();
const eps = 1e-6;
const sinA = Math.sin(angle);
if (angle < eps) {
// Near identity: axis undefined; return any unit axis (choose something stable)
Vec3.set(axis, 0, 0, 1);
angle = 0.0;
return { axis, angle };
}
if (Math.PI - angle > 1e-4) {
// General case
axis[0] = (R[5] - R[7]) / (2 * sinA); // (r32 - r23)
axis[1] = (R[6] - R[2]) / (2 * sinA); // (r13 - r31)
axis[2] = (R[1] - R[3]) / (2 * sinA); // (r21 - r12)
Vec3.normalize(axis, axis);
return { axis, angle };
}
// angle ~ π: use diagonal-based extraction for stability
// Compute squared components then pick the largest to avoid precision loss
const xx = Math.max(0, (R[0] + 1) * 0.5);
const yy = Math.max(0, (R[4] + 1) * 0.5);
const zz = Math.max(0, (R[8] + 1) * 0.5);
let x = Math.sqrt(xx), y = Math.sqrt(yy), z = Math.sqrt(zz);
if (x >= y && x >= z) {
x = Math.max(x, 1e-8);
y = (R[1] + R[3]) / (4 * x);
z = (R[2] + R[6]) / (4 * x);
Vec3.set(axis, x, y, z);
} else if (y >= x && y >= z) {
y = Math.max(y, 1e-8);
x = (R[1] + R[3]) / (4 * y);
z = (R[5] + R[7]) / (4 * y);
Vec3.set(axis, x, y, z);
} else {
z = Math.max(z, 1e-8);
x = (R[2] + R[6]) / (4 * z);
y = (R[5] + R[7]) / (4 * z);
Vec3.set(axis, x, y, z);
}
Vec3.normalize(axis, axis);
return { axis, angle: Math.PI };
}

View File

@@ -100,7 +100,10 @@ export type ElementOfSet<S> = S extends Set<infer T> ? T : never
/** Convert `colorString` (either X11 color name like 'magenta' or hex code like '#ff00ff') to Color.
* Return `undefined` if `colorString` cannot be converted. */
export function decodeColor(colorString: string | undefined | null): Color | undefined {
export function decodeColor(colorString: string | number | undefined | null): Color | undefined {
if (typeof colorString === 'number') {
return Color(colorString);
}
return _decodeColor(colorString);
}
@@ -149,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

@@ -46,7 +46,7 @@ export function loadTreeVirtual<TTree extends Tree, TContext>(
) {
const updateRoot: UpdateTarget = UpdateTarget.create(plugin, options?.replaceExisting ?? false);
loadTreeInUpdate(updateRoot, tree, loadingActions, context, options);
const stateTree: StateTree = updateRoot.update.getTree();
const stateTree: StateTree = updateRoot.update.getTree({ useHashVersion: true });
const stateSnapshot: State.Snapshot = { tree: StateTree.toJSON(stateTree) };
const pluginStateSnapshot: PluginState.Snapshot = { id: UUID.create22(), data: stateSnapshot };
return pluginStateSnapshot;

View File

@@ -62,6 +62,19 @@ export function transformFromRotationTranslation(rotation: number[] | null | und
return T;
}
export function decomposeRotationMatrix(rotation: number[] | null | undefined) {
if (rotation && rotation.length !== 9) throw new Error(`'rotation' param for 'transform' node must be array of 9 elements, found ${rotation}`);
if (rotation) {
const rotMatrix = Mat3.fromArray(Mat3(), rotation, 0);
ensureRotationMatrix(rotMatrix, rotMatrix);
const quat = Quat.fromMat3(Quat(), rotMatrix);
const axis = Vec3();
const angle = Quat.getAxisAngle(axis, quat) * 180 / Math.PI;
return { axis, angle };
}
return { axis: Vec3.create(1, 0, 0), angle: 0 };
}
/** Adjust values in a close-to-rotation matrix `a` to ensure it is a proper rotation matrix
* (i.e. its columns and rows are orthonormal and determinant equal to 1, within available precission). */
function ensureRotationMatrix(out: Mat3, a: Mat3) {
@@ -111,11 +124,32 @@ function transformProps(node: MolstarSubtree, kind: 'transform' | 'instance') {
for (const transform of transforms) {
let matrix: Mat4 | undefined = transform.params.matrix as Mat4 | undefined;
if (!matrix) {
const { rotation, translation } = transform.params;
const { rotation, translation, rotation_center } = transform.params;
if (rotation_center) {
const axisAngle = decomposeRotationMatrix(rotation);
result.push({
params: {
transform: {
name: 'components',
params: {
translation: translation ? Vec3.fromArray(Vec3(), translation, 0) : Vec3.create(0, 0, 0),
angle: axisAngle.angle,
axis: axisAngle.axis,
rotationCenter: rotation_center === 'centroid'
? { name: 'centroid', params: {} }
: { name: 'point', params: { point: Vec3.fromArray(Vec3(), rotation_center, 0) } }
}
}
},
ref: transform.ref
});
continue;
}
matrix = transformFromRotationTranslation(rotation, translation);
}
result.push({ params: { transform: { name: 'matrix', params: { data: matrix, transpose: false } } }, ref: transform.ref });
}
return result;
}
@@ -335,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 } },
@@ -348,11 +392,15 @@ function representationPropsBase(node: MolstarSubtree<'representation'>): Partia
return {
type: { name: 'carbohydrate', params: { alpha, sizeFactor: params.size_factor ?? 1 } },
};
case 'surface':
case 'surface': {
return {
type: { name: 'molecular-surface', params: { alpha, ignoreHydrogens: params.ignore_hydrogens } },
type: {
name: params.surface_type === 'gaussian' ? 'gaussian-surface' : 'molecular-surface',
params: { alpha, ignoreHydrogens: params.ignore_hydrogens }
},
sizeTheme: { name: 'physical', params: { scale: params.size_factor } },
};
}
default:
throw new Error('NotImplementedError');
}
@@ -364,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;
}
@@ -509,7 +557,7 @@ function appliesColorToWholeRepr(node: MolstarNode<'color' | 'color_from_uri' |
const FALLBACK_COLOR = decodeColor(DefaultColor)!;
function palettePropsFromMVSPalette(palette: MolstarNode<'color_from_uri' | 'color_from_source'>['params']['palette']): MVSAnnotationColorThemeProps['palette'] {
export function palettePropsFromMVSPalette(palette: MolstarNode<'color_from_uri' | 'color_from_source'>['params']['palette']): MVSAnnotationColorThemeProps['palette'] {
if (!palette) {
return { name: 'direct', params: {} };
}

View File

@@ -8,15 +8,17 @@
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';
import { PluginContext } from '../../mol-plugin/context';
import { StateObjectSelector } from '../../mol-state';
import { PluginState } from '../../mol-plugin/state';
import { StateObjectSelector, StateTree } from '../../mol-state';
import { RuntimeContext, Task } from '../../mol-task';
import { MolViewSpec } from './behavior';
import { createPluginStateSnapshotCamera, modifyCanvasProps } from './camera';
import { createPluginStateSnapshotCamera, modifyCanvasProps, resetCanvasProps } from './camera';
import { MVSAnnotationsProvider } from './components/annotation-prop';
import { MVSAnnotationStructureComponent } from './components/annotation-structure-component';
import { MVSAnnotationTooltipsProvider } from './components/annotation-tooltips-prop';
@@ -24,15 +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, SnapshotMetadata } from './mvs-data';
import { MVSData, MVSData_States, Snapshot, SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { validateTree } from './tree/generic/tree-schema';
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,12 +54,24 @@ export interface MVSLoadOptions {
doNotReportErrors?: boolean
}
export function loadMVS(plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
const task = Task.create('Load MVS', ctx => _loadMVS(ctx, plugin, data, options));
return plugin.runTask(task);
}
/** Load a MolViewSpec (MVS) state(s) into the Mol* plugin as plugin state snapshots. */
export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
async function _loadMVS(ctx: RuntimeContext, plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
plugin.errorContext.clear('mvs');
try {
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);
// console.log(`MVS tree:\n${MVSData.toPrettyString(data)}`)
const multiData: MVSData_States = data.kind === 'multiple' ? data : MVSData.stateToStates(data);
const entries: PluginStateSnapshotManager.Entry[] = [];
@@ -68,11 +85,16 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
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, i, multiData.snapshots.length);
entries.push(entry);
if (ctx.shouldUpdate) {
await ctx.update({ message: 'Loading MVS...', current: i, max: multiData.snapshots.length });
}
}
if (!options.appendSnapshots) {
plugin.managers.snapshot.clear();
@@ -80,6 +102,7 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
for (const entry of entries) {
plugin.managers.snapshot.add(entry);
}
if (entries.length > 0) {
await PluginCommands.State.Snapshots.Apply(plugin, { id: entries[0].snapshot.id });
}
@@ -101,24 +124,65 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
}
}
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 = {
autoplay: !!transitions.tree.params?.autoplay,
loop: !!transitions.tree.params?.loop,
frames: [],
};
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,
parent.animation,
{ ...parent.metadata, previousTransitionDurationMs: transitions.frametimeMs },
options
);
StateTree.reuseTransformParams(entry.snapshot.data!.tree, parentEntry.snapshot.data!.tree);
animation.frames.push({
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: `Loading animation for snapshot ${snapshotIndex + 1}/${snapshotCount}...`, current: i + 1, max: transitions.frames.length });
}
}
parentEntry.snapshot.transition = animation;
}
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,
@@ -165,31 +229,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, {
@@ -312,7 +417,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
},
primitives(updateParent: UpdateTarget, tree: MolstarSubtree<'primitives'>, context: MolstarLoadingContext): UpdateTarget {
const refs = getPrimitiveStructureRefs(tree);
const data = UpdateTarget.apply(updateParent, MVSInlinePrimitiveData, { node: tree });
const data = UpdateTarget.apply(updateParent, MVSInlinePrimitiveData, { node: tree as any });
return applyPrimitiveVisuals(data, refs);
},
primitives_from_uri(updateParent: UpdateTarget, tree: MolstarNode<'primitives_from_uri'>, context: MolstarLoadingContext): UpdateTarget {

View File

@@ -1,11 +1,13 @@
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { treeValidationIssues } from './tree/generic/tree-schema';
import { treeToString } from './tree/generic/tree-utils';
import { MVSAnimationSchema, MVSAnimationTree } from './tree/animation/animation-tree';
import { Root, createMVSBuilder } from './tree/mvs/mvs-builder';
import { MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
@@ -53,6 +55,8 @@ export interface Snapshot {
root: MVSTree,
/** Associated metadata */
metadata: SnapshotMetadata,
/** Optional animation */
animation?: MVSAnimationTree,
}
/** MVSData with a single state */
@@ -189,7 +193,14 @@ function majorVersion(semanticVersion: string | number): number | undefined {
function snapshotValidationIssues(snapshot: MVSData_State | Snapshot, options: { noExtra?: boolean } = {}): string[] | undefined {
if (snapshot.root === undefined) return [`"root" missing in snapshot`];
return treeValidationIssues(MVSTreeSchema, snapshot.root, options);
const state = treeValidationIssues(MVSTreeSchema, snapshot.root, options);
const animation = 'animation' in snapshot && snapshot.animation !== undefined
? treeValidationIssues(MVSAnimationSchema, snapshot.animation, options)
: undefined;
if (state && animation) return [...state, ...animation];
if (state) return state;
if (animation) return animation;
return undefined;
}
/** Return the current universal time, in ISO format, e.g. '2023-11-24T10:45:49.873Z' */

View File

@@ -0,0 +1,143 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { bool, float, int, list, OptionalField, RequiredField, str, union, nullable, literal, ValueFor } 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';
const Easing = literal(
'linear',
'bounce-in', 'bounce-out', 'bounce-in-out',
'circle-in', 'circle-out', 'circle-in-out',
'cubic-in', 'cubic-out', 'cubic-in-out',
'exp-in', 'exp-out', 'exp-in-out',
'quad-in', 'quad-out', 'quad-in-out',
'sin-in', 'sin-out', 'sin-in-out',
);
export type MVSAnimationEasing = ValueFor<typeof Easing>;
const _Noise = {
noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the interpolated value.')
// support cummulative noise?
};
const _Common = {
target_ref: RequiredField(str, 'Reference to the node.'),
property: RequiredField(union(str, list(union(str, int))), 'Value accessor.'),
start_ms: OptionalField(float, 0, 'Start time of the transition in milliseconds.'),
duration_ms: RequiredField(float, 'Duration of the transition in milliseconds.'),
};
const _Frequency = {
frequency: OptionalField(int, 1, 'Determines how many times the interpolation loops. Current T = frequency * t mod 1.'),
alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
};
const _Easing = {
easing: OptionalField(Easing, 'linear', 'Easing function to use for the transition.'),
};
const ScalarInterpolation = {
..._Common,
..._Frequency,
..._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,
};
const Vec3Interpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(list(float)), null, 'Start value. If unset, parent state value is used. Must be array of length 3N (x1, y1, z1, x2, y2, z2, ...).'),
end: OptionalField(nullable(list(float)), null, 'End value. Must be array of length 3N (x1, y1, z1, x2, y2, z2, ...). If unset, only noise is applied.'),
spherical: OptionalField(bool, false, 'Whether to use spherical interpolation.'),
..._Noise,
};
const RotationMatrixInterpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(Matrix), null, 'Start value. If unset, parent state value is used.'),
end: OptionalField(nullable(Matrix), null, 'End value. If unset, only noise is applied.'),
..._Noise,
};
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.'),
palette: OptionalField(nullable(union(DiscretePalette, ContinuousPalette)), null, 'Palette to sample colors from. Overrides start and end values.'),
};
const TransformationMatrixInterpolation = {
..._Common,
pivot: OptionalField(nullable(Vector3), null, 'Pivot point for rotation and scale.'),
rotation_start: OptionalField(nullable(Matrix), null, 'Start rotation value. If unset, parent state value is used.'),
rotation_end: OptionalField(nullable(Matrix), null, 'End rotation value. If unset, only noise is applied.'),
rotation_noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the rotation.'),
rotation_easing: OptionalField(Easing, 'linear', 'Easing function to use for the rotation.'),
rotation_frequency: OptionalField(int, 1, 'Determines how many times the rotation interpolation loops. Current T = frequency * t mod 1.'),
rotation_alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
translation_start: OptionalField(nullable(Vector3), null, 'Start translation value. If unset, parent state value is used.'),
translation_end: OptionalField(nullable(Vector3), null, 'End translation value. If unset, only noise is applied.'),
translation_noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the translation.'),
translation_easing: OptionalField(Easing, 'linear', 'Easing function to use for the translation.'),
translation_frequency: OptionalField(int, 1, 'Determines how many times the translation interpolation loops. Current T = frequency * t mod 1.'),
translation_alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
scale_start: OptionalField(nullable(Vector3), null, 'Start scale value. If unset, parent state value is used.'),
scale_end: OptionalField(nullable(Vector3), null, 'End scale value. If unset, only noise is applied.'),
scale_noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the scale.'),
scale_easing: OptionalField(Easing, 'linear', 'Easing function to use for the scale.'),
scale_frequency: OptionalField(int, 1, 'Determines how many times the scale interpolation loops. Current T = frequency * t mod 1.'),
scale_alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
};
export const MVSAnimationSchema = TreeSchema({
rootKind: 'animation',
nodes: {
animation: {
description: 'Animation root node',
parent: [],
params: SimpleParamsSchema({
frame_time_ms: OptionalField(float, 1000 / 60, 'Frame time in milliseconds'),
duration_ms: OptionalField(nullable(float), null, 'Total duration of the animation. If not specified, computed as maximum of all transitions.'),
autoplay: OptionalField(bool, true, 'Determines whether the animation should autoplay when a snapshot is loaded'),
loop: OptionalField(bool, false, 'Determines whether the animation should loop when it reaches the end'),
include_camera: OptionalField(bool, false, 'Determines whether the camera state should be included in the animation'),
include_canvas: OptionalField(bool, false, 'Determines whether the canvas state should be included in the animation'),
}),
},
interpolate: {
description: 'This node enables interpolating between values',
parent: ['animation'],
params: UnionParamsSchema(
'kind',
'Interpolation kind',
{
scalar: SimpleParamsSchema(ScalarInterpolation),
vec3: SimpleParamsSchema(Vec3Interpolation),
rotation_matrix: SimpleParamsSchema(RotationMatrixInterpolation),
transform_matrix: SimpleParamsSchema(TransformationMatrixInterpolation),
color: SimpleParamsSchema(ColorInterpolation),
},
)
}
}
});
export type MVSAnimationKind = keyof typeof MVSAnimationSchema.nodes
export type MVSAnimationNode<TKind extends MVSAnimationKind = MVSAnimationKind> = NodeFor<typeof MVSAnimationSchema, TKind>
export type MVSAnimationTree = TreeFor<typeof MVSAnimationSchema>
export type MVSAnimationNodeParams<TKind extends MVSAnimationKind> = ParamsOfKind<MVSAnimationTree, TKind>
export type MVSAnimationSubtree<TKind extends MVSAnimationKind = MVSAnimationKind> = SubtreeOfKind<MVSAnimationTree, TKind>

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

@@ -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

@@ -8,6 +8,7 @@
import { deepClone, pickObjectKeys } from '../../../../mol-util/object';
import { GlobalMetadata, MVSData_State, Snapshot, SnapshotMetadata } from '../../mvs-data';
import { CustomProps } from '../generic/tree-schema';
import { MVSAnimationNodeParams, MVSAnimationSubtree } from '../animation/animation-tree';
import { MVSKind, MVSNode, MVSNodeParams, MVSSubtree } from './mvs-tree';
@@ -50,6 +51,8 @@ class _Base<TKind extends MVSKind> {
/** MVS builder pointing to the 'root' node */
export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
protected _animation: Animation | undefined = undefined;
constructor(params_: CustomAndRef) {
const { custom, ref } = params_;
const node: MVSNode<'root'> = { kind: 'root', custom, ref };
@@ -69,6 +72,7 @@ export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
return {
root: deepClone(this._node),
metadata: { ...metadata },
animation: this?._animation ? deepClone(this._animation.node) : undefined,
};
}
@@ -89,6 +93,43 @@ export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
focus = bindMethod(this, FocusMixinImpl, 'focus');
primitives = bindMethod(this, PrimitivesMixinImpl, 'primitives');
primitives_from_uri = bindMethod(this, PrimitivesMixinImpl, 'primitives_from_uri');
animation(params: MVSAnimationNodeParams<'animation'> & CustomAndRef = {}): Animation {
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 {
private _node: MVSAnimationSubtree<'animation'>;
constructor(
parameters: MVSAnimationNodeParams<'animation'> & CustomAndRef
) {
this._node = {
kind: 'animation',
children: [],
...splitParams<MVSAnimationNodeParams<'animation'>>(parameters),
};
}
get node(): MVSAnimationSubtree<'animation'> {
return this._node;
}
interpolate(params: MVSAnimationNodeParams<'interpolate'> & CustomAndRef): Animation {
const node = {
kind: 'interpolate',
...splitParams<MVSAnimationNodeParams<'interpolate'>>(params)
} as MVSAnimationSubtree<'interpolate'>;
this._node.children!.push(node);
return this;
}
}
@@ -103,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'>)[] };
@@ -156,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

@@ -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.'),
@@ -35,6 +47,8 @@ const Carbohydrate = {
};
const Surface = {
/** Type of surface representation. (Default is 'molecular') */
surface_type: OptionalField(literal('molecular', 'gaussian'), 'molecular', `Type of surface representation. (Default is 'molecular')`),
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
/** Controls whether hydrogen atoms are drawn. */
@@ -46,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

@@ -57,6 +57,8 @@ const TransformParams = SimpleParamsSchema({
rotation: OptionalField(Matrix, [1, 0, 0, 0, 1, 0, 0, 0, 1], 'Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation).'),
/** Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation). */
translation: OptionalField(Vector3, [0, 0, 0], 'Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation).'),
/** Point to rotate the object around. Can be either a 3D vector or dynamically computed object centroid. */
rotation_center: OptionalField(nullable(union(Vector3, literal('centroid'))), null, 'Point to rotate the object around. Can be either a 3D vector or dynamically computed object centroid.'),
/** Transform matrix (4x4 matrix flattened in column major format (j*4+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. Takes precedence over `rotation` and `translation`. */
matrix: OptionalField(nullable(Matrix), null, 'Transform matrix (4x4 matrix flattened in column major format (j*4+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. Takes precedence over `rotation` and `translation`.'),
});
@@ -90,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.',
@@ -111,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. */
@@ -322,7 +332,7 @@ export const MVSTreeSchema = TreeSchema({
parent: ['root'],
params: SimpleParamsSchema({
/** Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
background_color: RequiredField(ColorT, 'Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
background_color: OptionalField(ColorT, 'white', 'Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). Defaults to white.'),
}),
},
primitives: {

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,7 +48,11 @@ 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 }),
};
export const Canvas3DParams = {
camera: PD.Group({
mode: PD.Select('perspective', PD.arrayToOptions(['perspective', 'orthographic'] as const), { label: 'Camera' }),
@@ -63,9 +66,7 @@ export const Canvas3DParams = {
manualReset: PD.Boolean(false, { isHidden: true }),
}, { pivot: 'mode' }),
cameraFog: PD.MappedStatic('on', {
on: PD.Group({
intensity: PD.Numeric(15, { min: 1, max: 100, step: 1 }),
}),
on: PD.Group(CameraFogParams),
off: PD.Group({})
}, { cycle: true, description: 'Show fog in the distance' }),
cameraClipping: PD.Group({
@@ -113,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 */
@@ -359,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']
@@ -378,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>();
@@ -420,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);
@@ -1179,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

@@ -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

@@ -126,6 +126,7 @@ export class SsaoPass {
private nSamples: number;
private blurKernelSize: number;
private texSize: [number, number];
private invProjection = Mat4.identity();
private ssaoScale: number;
private calcSsaoScale(resolutionScale: number) {
@@ -275,9 +276,7 @@ export class SsaoPass {
let needsUpdateDepthHalf = false;
const orthographic = camera.state.mode === 'orthographic' ? 1 : 0;
const invProjection = Mat4.identity();
Mat4.invert(invProjection, camera.projection);
const invProjection = Mat4.invert(this.invProjection, camera.projection);
const [w, h] = this.texSize;
const v = camera.viewport;

View File

@@ -1,8 +1,9 @@
/**
* Copyright (c) 2017-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-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>
* @author Adam Midlik <midlik@gmail.com>
*/
// from http://burtleburtle.net/bob/hash/integer.html
@@ -89,4 +90,456 @@ export function hashFnv32a(array: ArrayLike<number>) {
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}
return hval >>> 0;
}
/**
* 256 bit FNV-1a hash, returns 8 32-bit words
* Based on the FNV-1a algorithm extended to 256 bits
*/
export function hashFnv256a(array: ArrayLike<number>, out: Uint32Array) {
out.set(Fnv256Base);
for (let i = 0, il = array.length; i < il; ++i) {
// XOR with input byte
out[0] ^= array[i] & 0xff;
// Multiply by FNV prime (256-bit multiplication)
multiplyBy256BitPrime(out);
}
return out;
}
/**
* 256-bit object hash function using FNV-1a
*/
export function hashFnv256o(obj: any): string {
return _Hasher256.hash(obj);
}
class ObjectHasher256 {
private hashTarget: Uint32Array = new Uint32Array(8);
private numberBytes = new Uint8Array(8);
private numberView = new DataView(this.numberBytes.buffer);
hash(obj: any): string {
this.hashTarget.set(Fnv256Base);
this.hashValue(obj, 0);
return hashFnv256aToHex(this.hashTarget);
}
private hashValue(value: any, depth: number): void {
if (depth > 50) return;
const type = typeof value;
this.addByte(type.charCodeAt(0));
switch (type) {
case 'string':
this.addString(value);
break;
case 'number':
this.addNumber(value);
break;
case 'boolean':
this.addByte(value ? 1 : 0);
break;
case 'object':
if (value === null) {
this.addByte(0);
} else if (Array.isArray(value)) {
this.addArray(value, depth);
} else {
this.addObject(value, depth);
}
break;
case 'undefined':
this.addByte(255);
break;
}
}
private addByte(byte: number): void {
// XOR with input byte
this.hashTarget[0] ^= byte & 0xff;
// Multiply by FNV prime (256-bit multiplication)
multiplyBy256BitPrime(this.hashTarget);
}
private addString(str: string): void {
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 128) {
this.addByte(code);
} else if (code < 2048) {
this.addByte(0xc0 | (code >> 6));
this.addByte(0x80 | (code & 0x3f));
} else {
this.addByte(0xe0 | (code >> 12));
this.addByte(0x80 | ((code >> 6) & 0x3f));
this.addByte(0x80 | (code & 0x3f));
}
}
}
private addNumber(num: number): void {
if (Number.isNaN(num)) {
this.addByte(0x7f); this.addByte(0xc0); this.addByte(0x00); this.addByte(0x00);
this.addByte(0x00); this.addByte(0x00); this.addByte(0x00); this.addByte(0x00);
} else if (!Number.isFinite(num)) {
if (num > 0) {
this.addByte(0x7f); this.addByte(0x80); this.addByte(0x00); this.addByte(0x00);
} else {
this.addByte(0xff); this.addByte(0x80); this.addByte(0x00); this.addByte(0x00);
}
this.addByte(0x00); this.addByte(0x00); this.addByte(0x00); this.addByte(0x00);
} else {
this.numberView.setFloat64(0, num, false);
for (let i = 0; i < 8; i++) {
this.addByte(this.numberBytes[i]);
}
}
}
private addArray(arr: any[], depth: number): void {
this.addNumber(arr.length);
for (let i = 0; i < arr.length; i++) {
this.addNumber(i);
this.hashValue(arr[i], depth + 1);
}
}
private addObject(obj: any, depth: number): void {
const keys = Object.keys(obj).sort();
this.addNumber(keys.length);
for (const key of keys) {
this.addString(key);
this.hashValue(obj[key], depth + 1);
}
}
}
const _Hasher256 = new ObjectHasher256();
const Fnv256Base = new Uint32Array([
0x6c62272e, 0x07bb0142, 0x62b82175, 0x6295c58d,
0x16d67530, 0xdd7121e3, 0xb3174000, 0x00000100
]);
const MultTmp1 = new Uint32Array(8);
const MultTmp2 = new Uint32Array(8);
/**
* Helper function to multiply 256-bit number by FNV prime
*/
function multiplyBy256BitPrime(hash: Uint32Array): void {
// Since FNV 256-bit prime is 2^88 + 2^8 + 0x3b, we can optimize:
// 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);
MultTmp1[5] = (hash[2] >>> 8) | (hash[3] << 24);
MultTmp1[6] = (hash[3] >>> 8) | (hash[4] << 24);
MultTmp1[7] = (hash[4] >>> 8) | (hash[5] << 24);
// hash << 8
MultTmp2[0] = hash[0] << 8;
MultTmp2[1] = (hash[0] >>> 24) | (hash[1] << 8);
MultTmp2[2] = (hash[1] >>> 24) | (hash[2] << 8);
MultTmp2[3] = (hash[2] >>> 24) | (hash[3] << 8);
MultTmp2[4] = (hash[3] >>> 24) | (hash[4] << 8);
MultTmp2[5] = (hash[4] >>> 24) | (hash[5] << 8);
MultTmp2[6] = (hash[5] >>> 24) | (hash[6] << 8);
MultTmp2[7] = (hash[6] >>> 24) | (hash[7] << 8);
// hash * 0x3b (simple multiplication by small constant)
let carry = 0;
for (let i = 0; i < 8; i++) {
const product = hash[i] * 0x3b + carry;
hash[i] = product >>> 0;
carry = Math.floor(product / 0x100000000);
}
// Add all three components: (hash << 88) + (hash << 8) + hash * 0x3b
carry = 0;
for (let i = 0; i < 8; i++) {
const sum = hash[i] + MultTmp1[i] + MultTmp2[i] + carry;
hash[i] = sum >>> 0;
carry = sum >= 0x100000000 ? 1 : 0;
}
}
const _8digit_padding = [
'00000000',
'0000000',
'000000',
'00000',
'0000',
'000',
'00',
'0'
];
function padHexNumber(num: number): string {
const base = num.toString(16);
if (base.length >= 8) return base; // No padding needed
return _8digit_padding[base.length] + base;
}
/**
* Convert 256-bit hash to hex string
*/
function hashFnv256aToHex(hash: Uint32Array): string {
let result = '';
for (let i = 7; i >= 0; i--) {
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

@@ -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 David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -11,11 +11,10 @@ import { ReaderResult } from '../result';
import { Tokenizer } from '../common/text/tokenizer';
import { StringLike } from '../../common/string-like';
export function parsePDB(data: StringLike, id?: string, isPdbqt = false): Task<ReaderResult<PdbFile>> {
return Task.create('Parse PDB', async ctx => ReaderResult.success({
lines: await Tokenizer.readAllLinesAsync(data, ctx),
id,
isPdbqt
isPdbqt,
}));
}

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 David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -10,5 +10,5 @@ import { Tokens } from '../common/text/tokenizer';
export interface PdbFile {
lines: Tokens
id?: string,
isPdbqt?: boolean,
isPdbqt?: boolean
}

View File

@@ -126,6 +126,11 @@ namespace Mat3 {
return fromMat4(out, _m4);
}
export function fromRotation(out: Mat3, rad: number, axis: Vec3) {
Mat4.fromRotation(_m4, rad, axis);
return fromMat4(out, _m4);
}
export function create(a00: number, a01: number, a02: number, a10: number, a11: number, a12: number, a20: number, a21: number, a22: number): Mat3 {
const out = zero();
out[0] = a00;

View File

@@ -1270,6 +1270,14 @@ namespace Mat4 {
return Math.sqrt(Math.max(scaleXSq, scaleYSq, scaleZSq));
}
export function extractBasis(m: Mat4) {
return {
x: Vec3.create(m[0], m[1], m[2]),
y: Vec3.create(m[4], m[5], m[6]),
z: Vec3.create(m[8], m[9], m[10])
};
}
const xAxis = [1, 0, 0] as unknown as Vec3;
const yAxis = [0, 1, 0] as unknown as Vec3;
const zAxis = [0, 0, 1] as unknown as Vec3;

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2019 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>
* @author Kim Juho <juho_kim@outlook.com>
*/
import { CifField } from '../../../mol-io/reader/cif';
@@ -94,8 +95,10 @@ export function addAnisotropic(sites: AnisotropicTemplate, model: string, data:
TokenBuilder.add(sites.pdbx_label_alt_id, s + 16, s + 17);
}
// 18 - 20 Residue name resName Residue name.
TokenBuilder.addToken(sites.pdbx_auth_comp_id, Tokenizer.trim(data, s + 17, s + 20));
// 18 - 21 Residue name resName Residue name.
// PDB spec defines 3-letter
// but 4-letter are commonly used
TokenBuilder.addToken(sites.pdbx_auth_comp_id, Tokenizer.trim(data, s + 17, s + 21));
// 22 Character chainID Chain identifier.
TokenBuilder.add(sites.pdbx_auth_asym_id, s + 21, s + 22);

View File

@@ -1,8 +1,9 @@
/**
* Copyright (c) 2019-2024 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 David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Kim Juho <juho_kim@outlook.com>
*/
import { CifField } from '../../../mol-io/reader/cif';
@@ -237,8 +238,10 @@ export function addAtom(sites: AtomSiteTemplate, model: string, data: Tokenizer,
TokenBuilder.add(sites.label_alt_id, s + 16, s + 17);
}
// 18 - 20 Residue name Residue name.
TokenBuilder.addToken(sites.auth_comp_id, Tokenizer.trim(data, s + 17, s + 20));
// 18 - 21 Residue name Residue name.
// PDB spec defines 3-letter
// but 4-letter are commonly used
TokenBuilder.addToken(sites.auth_comp_id, Tokenizer.trim(data, s + 17, s + 21));
// 22 Character Chain identifier.
TokenBuilder.add(sites.auth_asym_id, s + 21, s + 22);

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2019 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>
* @author Kim Juho <juho_kim@outlook.com>
*/
import { CifCategory, CifField } from '../../../mol-io/reader/cif';
@@ -62,18 +63,22 @@ export function parseHelix(lines: Tokens, lineStart: number, lineEnd: number): C
const line = getLine(i);
// COLUMNS DATA TYPE FIELD DEFINITION
// -----------------------------------------------------------------------------------
// 1 - 6 Record name "HELIX "
// 8 - 10 Integer serNum Serial number of the helix. This starts
// 1 - 6 Record name "HELIX "
// 8 - 10 Integer serNum Serial number of the helix. This starts
// at 1 and increases incrementally.
// 12 - 14 LString(3) helixID Helix identifier. In addition to a serial
// number, each helix is given an
// alphanumeric character helix identifier.
// 16 - 18 Residue name initResName Name of the initial residue.
// 16 - 19 Residue name initResName Name of the initial residue.
// PDB spec defines 3-letter for residue name,
// but 4-letter are commonly used
// 20 Character initChainID Chain identifier for the chain containing
// this helix.
// 22 - 25 Integer initSeqNum Sequence number of the initial residue.
// 26 AChar initICode Insertion code of the initial residue.
// 28 - 30 Residue name endResName Name of the terminal residue of the helix.
// 28 - 31 Residue name endResName Name of the terminal residue of the helix.
// PDB spec defines 3-letter for residue name,
// but 4-letter are commonly used
// 32 Character endChainID Chain identifier for the chain containing
// this helix.
// 34 - 37 Integer endSeqNum Sequence number of the terminal residue.
@@ -82,19 +87,19 @@ export function parseHelix(lines: Tokens, lineStart: number, lineEnd: number): C
// 41 - 70 String comment Comment about this helix.
// 72 - 76 Integer length Length of this helix.
helices.push({
serNum: line.substr(7, 3).trim(),
helixID: line.substr(11, 3).trim(),
initResName: line.substr(15, 3).trim(),
initChainID: line.substr(19, 1).trim(),
initSeqNum: line.substr(21, 4).trim(),
initICode: line.substr(25, 1).trim(),
endResName: line.substr(27, 3).trim(),
endChainID: line.substr(31, 3).trim(),
endSeqNum: line.substr(33, 4).trim(),
endICode: line.substr(37, 1).trim(),
helixClass: line.substr(38, 2).trim(),
comment: line.substr(40, 30).trim(),
length: line.substr(71, 5).trim()
serNum: line.substring(7, 10).trim(),
helixID: line.substring(11, 14).trim(),
initResName: line.substring(15, 19).trim(),
initChainID: line.substring(19, 20).trim(),
initSeqNum: line.substring(21, 25).trim(),
initICode: line.substring(25, 26).trim(),
endResName: line.substring(27, 31).trim(),
endChainID: line.substring(31, 34).trim(),
endSeqNum: line.substring(33, 37).trim(),
endICode: line.substring(37, 38).trim(),
helixClass: line.substring(38, 40).trim(),
comment: line.substring(40, 70).trim(),
length: line.substring(71, 76).trim()
});
}
@@ -167,19 +172,23 @@ export function parseSheet(lines: Tokens, lineStart: number, lineEnd: number): C
const line = getLine(i);
// COLUMNS DATA TYPE FIELD DEFINITION
// -------------------------------------------------------------------------------------
// 1 - 6 Record name "SHEET "
// 8 - 10 Integer strand Strand number which starts at 1 for each
// 1 - 6 Record name "SHEET "
// 8 - 10 Integer strand Strand number which starts at 1 for each
// strand within a sheet and increases by one.
// 12 - 14 LString(3) sheetID Sheet identifier.
// 15 - 16 Integer numStrands Number of strands in sheet.
// 18 - 20 Residue name initResName Residue name of initial residue.
// 18 - 21 Residue name initResName Residue name of initial residue.
// PDB spec defines 3-letter for residue name,
// but 4-letter are commonly used
// 22 Character initChainID Chain identifier of initial residue
// in strand.
// 23 - 26 Integer initSeqNum Sequence number of initial residue
// in strand.
// 27 AChar initICode Insertion code of initial residue
// in strand.
// 29 - 31 Residue name endResName Residue name of terminal residue.
// 29 - 32 Residue name endResName Residue name of terminal residue.
// PDB spec defines 3-letter for residue name,
// but 4-letter are commonly used
// 33 Character endChainID Chain identifier of terminal residue.
// 34 - 37 Integer endSeqNum Sequence number of terminal residue.
// 38 AChar endICode Insertion code of terminal residue.
@@ -187,7 +196,9 @@ export function parseSheet(lines: Tokens, lineStart: number, lineEnd: number): C
// strand in the sheet. 0 if first strand,
// 1 if parallel,and -1 if anti-parallel.
// 42 - 45 Atom curAtom Registration. Atom name in current strand.
// 46 - 48 Residue name curResName Registration. Residue name in current strand
// 46 - 49 Residue name curResName Registration. Residue name in current strand
// PDB spec defines 3-letter for residue name,
// but 4-letter are commonly used
// 50 Character curChainId Registration. Chain identifier in
// current strand.
// 51 - 54 Integer curResSeq Registration. Residue sequence number
@@ -195,8 +206,10 @@ export function parseSheet(lines: Tokens, lineStart: number, lineEnd: number): C
// 55 AChar curICode Registration. Insertion code in
// current strand.
// 57 - 60 Atom prevAtom Registration. Atom name in previous strand.
// 61 - 63 Residue name prevResName Registration. Residue name in
// 61 - 64 Residue name prevResName Registration. Residue name in
// previous strand.
// PDB spec defines 3-letter for residue name,
// but 4-letter are commonly used
// 65 Character prevChainId Registration. Chain identifier in
// previous strand.
// 66 - 69 Integer prevResSeq Registration. Residue sequence number
@@ -204,28 +217,28 @@ export function parseSheet(lines: Tokens, lineStart: number, lineEnd: number): C
// 70 AChar prevICode Registration. Insertion code in
// previous strand.
sheets.push({
strand: line.substr(7, 3).trim(),
sheetID: line.substr(11, 3).trim(),
numStrands: line.substr(14, 2).trim(),
initResName: line.substr(17, 3).trim(),
initChainID: line.substr(21, 1).trim(),
initSeqNum: line.substr(22, 4).trim(),
initICode: line.substr(26, 1).trim(),
endResName: line.substr(28, 3).trim(),
endChainID: line.substr(32, 1).trim(),
endSeqNum: line.substr(33, 4).trim(),
endICode: line.substr(37, 1).trim(),
sense: line.substr(38, 2).trim(),
curAtom: line.substr(41, 4).trim(),
curResName: line.substr(45, 3).trim(),
curChainId: line.substr(49, 1).trim(),
curResSeq: line.substr(50, 4).trim(),
curICode: line.substr(54, 1).trim(),
prevAtom: line.substr(56, 4).trim(),
prevResName: line.substr(60, 3).trim(),
prevChainId: line.substr(64, 1).trim(),
prevResSeq: line.substr(65, 4).trim(),
prevICode: line.substr(69, 1).trim(),
strand: line.substring(7, 10).trim(),
sheetID: line.substring(11, 14).trim(),
numStrands: line.substring(14, 16).trim(),
initResName: line.substring(17, 21).trim(),
initChainID: line.substring(21, 22).trim(),
initSeqNum: line.substring(22, 26).trim(),
initICode: line.substring(26, 27).trim(),
endResName: line.substring(28, 32).trim(),
endChainID: line.substring(32, 33).trim(),
endSeqNum: line.substring(33, 37).trim(),
endICode: line.substring(37, 38).trim(),
sense: line.substring(38, 40).trim(),
curAtom: line.substring(41, 45).trim(),
curResName: line.substring(45, 49).trim(),
curChainId: line.substring(49, 50).trim(),
curResSeq: line.substring(50, 54).trim(),
curICode: line.substring(54, 55).trim(),
prevAtom: line.substring(56, 60).trim(),
prevResName: line.substring(60, 64).trim(),
prevChainId: line.substring(64, 65).trim(),
prevResSeq: line.substring(65, 69).trim(),
prevICode: line.substring(69, 70).trim(),
});
}

View File

@@ -1,39 +1,46 @@
/**
* Copyright (c) 2019 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 David Sehnal <david.sehnal@gmail.com>
*/
import { PluginContext } from '../../../mol-plugin/context';
import { PluginState } from '../../../mol-plugin/state';
import { PluginStateSnapshotManager } from '../../manager/snapshots';
import { PluginStateAnimation } from '../model';
async function setPartialSnapshot(plugin: PluginContext, entry: PluginStateSnapshotManager.Entry, first = false) {
if (entry.snapshot.data) {
await plugin.runTask(plugin.state.data.setSnapshot(entry.snapshot.data));
async function setPartialSnapshot(plugin: PluginContext, entry: Partial<PluginStateSnapshotManager.Entry['snapshot']>, first = false) {
if (entry.data) {
await plugin.runTask(plugin.state.data.setSnapshot(entry.data));
// update the canvas3d trackball with the snapshot
plugin.canvas3d?.setProps({
trackball: entry.snapshot.canvas3d?.props?.trackball
trackball: entry.canvas3d?.props?.trackball
});
}
if (entry.snapshot.camera?.current) {
if (entry.camera?.current) {
plugin.canvas3d?.requestCameraReset({
snapshot: entry.snapshot.camera.current,
durationMs: first || entry.snapshot.camera.transitionStyle === 'instant'
? 0 : entry.snapshot.camera.transitionDurationInMs,
snapshot: entry.camera.current,
durationMs: first || entry.camera.transitionStyle === 'instant'
? 0 : entry.camera.transitionDurationInMs,
});
} else if (entry.snapshot.camera?.focus) {
} else if (entry.camera?.focus) {
plugin.managers.camera.focusObject({
...entry.snapshot.camera.focus,
durationMs: first || entry.snapshot.camera.transitionStyle === 'instant'
? 0 : entry.snapshot.camera.transitionDurationInMs,
...entry.camera.focus,
durationMs: first || entry.camera.transitionStyle === 'instant'
? 0 : entry.camera.transitionDurationInMs,
});
}
}
type State = { totalDuration: number, snapshots: PluginStateSnapshotManager.Entry[], currentIndex: 0 };
type State = {
totalDuration: number,
snapshots: PluginStateSnapshotManager.Entry[],
currentIndex: number,
currentTransitionFrame?: number,
isInitial?: boolean,
};
export const AnimateStateSnapshots = PluginStateAnimation.create({
name: 'built-in.animate-state-snapshots',
@@ -42,17 +49,17 @@ export const AnimateStateSnapshots = PluginStateAnimation.create({
params: () => ({}),
canApply(plugin) {
const entries = plugin.managers.snapshot.state.entries;
if (entries.size < 2) {
return { canApply: false, reason: 'At least 2 states required.' };
if (entries.size < 1) {
return { canApply: false, reason: 'At least 1 state required.' };
}
if (entries.some(e => !!e?.snapshot.startAnimation)) {
return { canApply: false, reason: 'Nested animations not supported.' };
}
return { canApply: plugin.managers.snapshot.state.entries.size > 1 };
return { canApply: plugin.managers.snapshot.state.entries.size > 0 };
},
setup(_, __, plugin) {
const pivot = plugin.managers.snapshot.state.entries.get(0)!;
setPartialSnapshot(plugin, pivot, true);
setPartialSnapshot(plugin, pivot.snapshot, true);
},
getDuration: (_, plugin) => {
return {
@@ -66,7 +73,8 @@ export const AnimateStateSnapshots = PluginStateAnimation.create({
return {
totalDuration: snapshots.reduce((a, b) => a + (b.snapshot.durationInMs ?? 0), 0),
snapshots,
currentIndex: 0
currentIndex: 0,
currentTransitionFrame: 0,
} as State;
},
async apply(animState: State, t, ctx) {
@@ -75,7 +83,9 @@ export const AnimateStateSnapshots = PluginStateAnimation.create({
}
let ctime = 0, i = 0;
let ftime = 0;
for (const s of animState.snapshots) {
ftime = t.current - ctime;
ctime += s.snapshot.durationInMs ?? 0;
if (t.current < ctime) {
break;
@@ -85,12 +95,114 @@ export const AnimateStateSnapshots = PluginStateAnimation.create({
if (i >= animState.snapshots.length) return { kind: 'finished' };
const { transition, camera, canvas3d } = animState.snapshots[i].snapshot;
const frameIndex = PluginState.getStateTransitionFrameIndex(animState.snapshots[i].snapshot, ftime);
if (transition && frameIndex !== undefined) {
if (i === animState.currentIndex && frameIndex === animState.currentTransitionFrame) {
return { kind: 'skip' };
}
if (frameIndex === 0 || i !== animState.currentIndex) {
await setPartialSnapshot(ctx.plugin, {
...transition.frames[frameIndex],
camera,
canvas3d,
});
} else {
await setPartialSnapshot(ctx.plugin, transition.frames[frameIndex]);
}
return { kind: 'next', state: { ...animState, currentIndex: i, currentAnimationFrame: frameIndex } };
}
if (i === animState.currentIndex) {
return { kind: 'skip' };
}
await setPartialSnapshot(ctx.plugin, animState.snapshots[i]);
await setPartialSnapshot(ctx.plugin, animState.snapshots[i].snapshot);
return { kind: 'next', state: { ...animState, currentIndex: i, currentAnimationFrame: undefined } };
}
});
return { kind: 'next', state: { ...animState, currentIndex: i } };
export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
name: 'built-in.animate-state-snapshot-transition',
display: { name: 'State Snapshot Transition' },
isExportable: true,
params: () => ({}),
canApply(plugin) {
const { snapshot } = plugin.managers;
const { current } = snapshot;
if (!current?.snapshot.transition) {
return { canApply: false, reason: 'No transition found' };
}
return { canApply: true };
},
setup(_, __, plugin) {
const { current } = plugin.managers.snapshot;
if (!current) return;
setPartialSnapshot(plugin, current.snapshot.transition?.frames[0] ?? current.snapshot, true);
},
getDuration: (_, plugin) => {
const { current } = plugin.managers.snapshot;
if (!current?.snapshot.transition) return { kind: 'fixed', durationMs: 0 };
if (current.snapshot.transition?.loop) {
return { kind: 'infinite' };
}
return {
kind: 'fixed',
durationMs: PluginState.getStateTransitionDuration(current.snapshot) ?? 0
};
},
initialState: (_, plugin) => {
const { current } = plugin.managers.snapshot;
if (!current) return;
return {
totalDuration: current.snapshot.transition?.loop ? Number.MAX_VALUE : (PluginState.getStateTransitionDuration(current.snapshot) ?? 0),
snapshots: [current],
currentIndex: 0,
currentTransitionFrame: undefined,
isInitial: true,
} as State;
},
async apply(animState: State, t, ctx) {
const snapshot = animState.snapshots[0]?.snapshot;
if (t.current >= animState.totalDuration) {
if (snapshot?.transition && animState.isInitial) {
const frameIndex = snapshot.transition.frames.length - 1;
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(animState.totalDuration, false);
await setPartialSnapshot(ctx.plugin, snapshot.transition.frames[frameIndex]);
}
return { kind: 'finished' };
}
if (!snapshot) return { kind: 'finished' };
const { transition, camera, canvas3d } = snapshot;
const frameIndex = PluginState.getStateTransitionFrameIndex(snapshot, t.current);
if (!transition || frameIndex === undefined) {
return { kind: 'finished' };
}
if (frameIndex === animState.currentTransitionFrame) {
return { kind: 'skip' };
}
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(t.current, false);
if (frameIndex === 0) {
await setPartialSnapshot(ctx.plugin, {
...transition.frames[frameIndex],
camera,
canvas3d,
});
} else {
await setPartialSnapshot(ctx.plugin, transition.frames[frameIndex]);
}
return { kind: 'next', state: { ...animState, currentAnimationFrame: frameIndex, isInitial: false } };
}
});

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019 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 David Sehnal <david.sehnal@gmail.com>
*/
@@ -33,6 +33,10 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
get animations() { return this._animations; }
get isAnimatingStateTransition() {
return this._current.anim.name === 'built-in.animate-state-snapshot-transition';
}
private triggerUpdate() {
this.events.updated.next(void 0);
}
@@ -150,6 +154,11 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
}
}
stopStateTransitionAnimation() {
if (!this.isAnimatingStateTransition) return;
return this.stop();
}
get isAnimating() {
return this.state.animationState === 'playing';
}

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

@@ -19,20 +19,32 @@ import { PLUGIN_VERSION } from '../../mol-plugin/version';
import { canvasToBlob } from '../../mol-canvas3d/util';
import { Task } from '../../mol-task';
import { StringLike } from '../../mol-io/common/string-like';
import { SingleTaskQueue } from '../../mol-util/single-task-queue';
export { PluginStateSnapshotManager };
class PluginStateSnapshotManager extends StatefulPluginComponent<{
current?: UUID | undefined,
interface StateManagerState {
current?: UUID,
currentAnimationTimeMs?: number,
entries: List<PluginStateSnapshotManager.Entry>,
isPlaying: boolean,
nextSnapshotDelayInMs: number
}> {
}
class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerState> {
static DefaultNextSnapshotDelayInMs = 1500;
private entryMap = new Map<string, PluginStateSnapshotManager.Entry>();
private defaultSnapshotId: UUID | undefined = undefined;
protected updateState(state: Partial<StateManagerState>) {
if ('current' in state && !('currentAnimationTimeMs' in state)) {
return super.updateState({ ...state, currentAnimationTimeMs: 0 });
} else {
return super.updateState(state);
}
}
readonly events = {
changed: this.ev(),
opened: this.ev(),
@@ -155,6 +167,26 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
return e && e.snapshot;
}
private animationFrameQueue = new SingleTaskQueue();
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(() => {
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frameIndex ?? 0);
});
}
return frameIndex;
}
getNextId(id: string | undefined, dir: -1 | 1) {
const len = this.state.entries.size;
if (!id) {
@@ -183,11 +215,6 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
}
async setStateSnapshot(snapshot: PluginStateSnapshotManager.StateSnapshot): Promise<PluginState.Snapshot | undefined> {
if (snapshot.version !== PLUGIN_VERSION) {
// TODO
// console.warn('state snapshot version mismatch');
}
this.clear();
const entries = List<PluginStateSnapshotManager.Entry>().asMutable();
for (const e of snapshot.entries) {
@@ -211,7 +238,7 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
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;
}
@@ -343,10 +370,33 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
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();
@@ -357,23 +407,24 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
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

@@ -49,18 +49,48 @@ export function getPlaneDataFromStructureSelections(s: ReadonlyArray<PluginState
return { locis: s.map(v => v.loci) };
}
export function getTransformFromParams(src:
| { name: 'matrix', params: { data: Mat4, transpose?: boolean } }
| { name: 'components', params: { translation: Vec3, axis: Vec3, angle: number } }
) {
const GetTransformState = {
center: Vec3(),
rotation: Mat4(),
translationToCenter: Mat4(),
translationFromCenter: Mat4(),
translation: Mat4(),
local: Mat4(),
};
export function transformParamsNeedCentroid(src: TransformParam) {
if (src.name === 'components' && src.params.rotationCenter?.name === 'centroid') {
return true;
}
return false;
}
export function getTransformFromParams(src: TransformParam, centroid: Vec3) {
if (src.name === 'matrix') {
const transform = Mat4();
Mat4.copy(transform, src.params.data);
if (src.params.transpose) Mat4.transpose(transform, transform);
return transform;
} else {
const transform = Mat4.fromRotation(Mat4(), src.params.angle * Math.PI / 180, src.params.axis);
Mat4.setTranslation(transform, src.params.translation);
if (src.params.rotationCenter?.name === 'centroid') {
Vec3.copy(GetTransformState.center, centroid);
} else if (src.params.rotationCenter?.name === 'point') {
Vec3.copy(GetTransformState.center, src.params.rotationCenter.params.point);
} else {
Vec3.set(GetTransformState.center, 0, 0, 0);
}
Mat4.fromTranslation(GetTransformState.translationToCenter, GetTransformState.center);
Mat4.fromRotation(GetTransformState.rotation, src.params.angle * Math.PI / 180, src.params.axis);
Mat4.fromTranslation(GetTransformState.translationFromCenter, Vec3.negate(GetTransformState.center, GetTransformState.center));
const transform = Mat4.mul3(
Mat4(),
GetTransformState.translationToCenter,
GetTransformState.rotation,
GetTransformState.translationFromCenter,
);
Mat4.fromTranslation(GetTransformState.translation, src.params.translation);
Mat4.mul(transform, GetTransformState.translation, transform);
return transform;
}
}
@@ -80,9 +110,15 @@ export const TransformParam = PD.MappedStatic(
translation: PD.Vec3(Vec3.create(0, 0, 0)),
axis: PD.Vec3(Vec3.create(1, 0, 0)),
angle: PD.Numeric(0, { min: -360, max: 360, step: 1 }, { description: 'Angle in Degrees' }),
rotationCenter: PD.MappedStatic('point', {
point: PD.Group({ point: PD.Vec3(Vec3.create(0, 0, 0)) }, { isFlat: true }),
centroid: PD.Group({})
}),
},
{ isFlat: true }
),
},
{ label: 'Kind' },
);
);
export type TransformParam = (typeof TransformParam)['defaultValue']

View File

@@ -55,7 +55,7 @@ import { parseNctraj } from '../../mol-io/reader/nctraj/parser';
import { coordinatesFromNctraj } from '../../mol-model-formats/structure/nctraj';
import { topologyFromPrmtop } from '../../mol-model-formats/structure/prmtop';
import { topologyFromTop } from '../../mol-model-formats/structure/top';
import { getTransformFromParams, TransformParam } from './helpers';
import { getTransformFromParams, TransformParam, transformParamsNeedCentroid } from './helpers';
export { CoordinatesFromDcd };
export { CoordinatesFromXtc };
@@ -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);
@@ -355,7 +355,7 @@ const TrajectoryFromPDB = PluginStateTransform.BuiltIn({
from: [SO.Data.String],
to: SO.Molecule.Trajectory,
params: {
isPdbqt: PD.Boolean(false)
isPdbqt: PD.Boolean(false),
}
})({
apply({ a, params }) {
@@ -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}`;
@@ -644,8 +644,6 @@ const StructureFromModel = PluginStateTransform.BuiltIn({
}
});
const _translation = Vec3(), _m = Mat4(), _n = Mat4();
type TransformStructureConformation = typeof TransformStructureConformation
const TransformStructureConformation = PluginStateTransform.BuiltIn({
name: 'transform-structure-conformation',
@@ -661,23 +659,8 @@ const TransformStructureConformation = PluginStateTransform.BuiltIn({
return newParams.transform.name !== 'matrix';
},
apply({ a, params }) {
// TODO: optimze
// TODO: think of ways how to fast-track changes to this for animations
const transform = Mat4();
if (params.transform.name === 'components') {
const { axis, angle, translation } = params.transform.params;
const center = a.data.boundary.sphere.center;
Mat4.fromTranslation(_m, Vec3.negate(_translation, center));
Mat4.fromTranslation(_n, Vec3.add(_translation, center, translation));
const rot = Mat4.fromRotation(Mat4(), Math.PI / 180 * angle, Vec3.normalize(Vec3(), axis));
Mat4.mul3(transform, _n, rot, _m);
} else if (params.transform.name === 'matrix') {
Mat4.copy(transform, params.transform.params.data);
if (params.transform.params.transpose) Mat4.transpose(transform, transform);
}
const center = transformParamsNeedCentroid(params.transform) ? a.data.boundary.sphere.center : Vec3.unit;
const transform = getTransformFromParams(params.transform, center);
const s = Structure.transform(a.data, transform);
return new SO.Molecule.Structure(s, { label: a.label, description: `${a.description} [Transformed]` });
},
@@ -715,7 +698,8 @@ const StructureInstances = PluginStateTransform.BuiltIn({
return true;
},
apply({ a, params }) {
const instances = params.transforms.map(t => getTransformFromParams(t.transform));
const center = params.transforms.some(t => transformParamsNeedCentroid(t.transform)) ? a.data.boundary.sphere.center : Vec3.unit;
const instances = params.transforms.map(t => getTransformFromParams(t.transform, center));
if (!instances.length) {
return a;
}

View File

@@ -20,7 +20,7 @@ import { Grid, Volume } from '../../mol-model/volume';
import { PluginContext } from '../../mol-plugin/context';
import { StateSelection } from '../../mol-state';
import { volumeFromSegmentationData } from '../../mol-model-formats/volume/segmentation';
import { getTransformFromParams, TransformParam } from './helpers';
import { getTransformFromParams, TransformParam, transformParamsNeedCentroid } from './helpers';
export { VolumeFromCcp4 };
export { VolumeFromDsn6 };
@@ -248,7 +248,8 @@ export const VolumeTransform = PluginStateTransform.BuiltIn({
},
apply({ a, params }) {
// similar to StateTransforms.Model.TransformStructureConformation;
const transform = getTransformFromParams(params.transform);
const center = transformParamsNeedCentroid(params.transform) ? Grid.getBoundingSphere(a.data.grid).center : Vec3.unit;
const transform = getTransformFromParams(params.transform, center);
const gridTransform = {
kind: 'matrix' as const,
matrix: Mat4.mul(Mat4(), transform, Grid.getGridToCartesianTransform(a.data.grid)),
@@ -281,13 +282,14 @@ export const VolumeInstances = PluginStateTransform.BuiltIn({
return true;
},
apply({ a, params }) {
const instances = params.transforms.map(t => ({ transform: getTransformFromParams(t.transform) }));
const center = params.transforms.some(t => transformParamsNeedCentroid(t.transform)) ? Grid.getBoundingSphere(a.data.grid).center : Vec3.unit;
const instances = params.transforms.map(t => ({ transform: getTransformFromParams(t.transform, center) }));
if (!instances.length) {
return a;
}
return new SO.Volume.Data({
...a.data,
instances: params.transforms.map(t => ({ transform: getTransformFromParams(t.transform) })),
instances,
}, {
label: a.label,
description: `${a.description} [Instanced]`,

View File

@@ -16,7 +16,7 @@ import { PluginCommands } from '../mol-plugin/commands';
import { StateTransformer } from '../mol-state';
import { PluginReactContext, PluginUIComponent } from './base';
import { IconButton } from './controls/common';
import { Icon, NavigateBeforeSvg, NavigateNextSvg, SkipPreviousSvg, StopSvg, PlayArrowSvg, SubscriptionsOutlinedSvg, BuildSvg } from './controls/icons';
import { Icon, NavigateBeforeSvg, NavigateNextSvg, SkipPreviousSvg, StopSvg, PlayArrowSvg, SubscriptionsOutlinedSvg, BuildSvg, AnimationSvg, RefreshSvg } from './controls/icons';
import { AnimationControls } from './state/animation';
import { StructureComponentControls } from './structure/components';
import { StructureMeasurementsControls } from './structure/measurements';
@@ -27,6 +27,9 @@ import { PluginConfig } from '../mol-plugin/config';
import { StructureSuperpositionControls } from './structure/superposition';
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: '' };
@@ -59,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}`;
}
}
}
@@ -102,45 +105,19 @@ export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: bo
}
}
export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBusy: boolean, show: boolean }> {
state = { isBusy: false, show: true };
export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBusy: boolean, show: boolean, showAnimation: boolean }> {
state = { isBusy: false, show: true, showAnimation: false };
componentDidMount() {
// TODO: this needs to be diabled when the state is updating!
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 });
@@ -168,27 +145,65 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
this.plugin.managers.snapshot.togglePlay();
};
toggleShowAnimation = () => {
this.setState({ showAnimation: !this.state.showAnimation });
};
toggleStateAnimation = () => {
if (this.state.isBusy) {
this.plugin.managers.animation.stop();
this.plugin.managers.markdownExtensions.audio.pause();
} else {
this.plugin.managers.animation.play(AnimateStateSnapshotTransition, {});
}
};
get isStateTransitionPlaying() {
return this.plugin.managers.animation.isAnimatingStateTransition;
}
render() {
const snapshots = this.plugin.managers.snapshot;
const count = snapshots.state.entries.size;
if (count < 2 || !this.state.show) {
if (!count || !this.state.show) {
return null;
}
const current = snapshots.state.current;
const isPlaying = snapshots.state.isPlaying;
const entry = snapshots.getEntry(current);
const hasAnimation = !!entry?.snapshot.transition;
const disabled = isPlaying || (this.state.isBusy && !this.isStateTransitionPlaying);
return <div className='msp-state-snapshot-viewport-controls'>
<select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy || isPlaying}>
<select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={disabled}>
{!current && <option key='none' value='none'></option>}
{snapshots.state.entries.valueSeq().map((e, i) => <option key={e!.snapshot.id} value={e!.snapshot.id}>{`[${i! + 1}/${count}]`} {e!.name || new Date(e!.timestamp).toLocaleString()}</option>)}
</select>
<IconButton svg={isPlaying ? StopSvg : PlayArrowSvg} title={isPlaying ? 'Pause' : 'Cycle States'} onClick={this.togglePlay}
disabled={isPlaying ? false : this.state.isBusy} />
disabled={isPlaying ? false : (this.state.isBusy && !this.isStateTransitionPlaying)} />
{!isPlaying && <>
<IconButton svg={NavigateBeforeSvg} title='Previous State' onClick={this.prev} disabled={this.state.isBusy || isPlaying} />
<IconButton svg={NavigateNextSvg} title='Next State' onClick={this.next} disabled={this.state.isBusy || 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='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={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, true)}
hideInput
disabled={this.state.isBusy}
/>
&nbsp;
</div>
<IconButton svg={this.state.isBusy ? StopSvg : RefreshSvg} title={this.state.isBusy ? 'Stop' : 'Replay'} onClick={this.toggleStateAnimation} toggleState={false} />
</>}
</div>;
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2020-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>
@@ -70,6 +70,8 @@ export function AccountTreeOutlinedSvg() { return _AccountTreeOutlined; }
const _Add = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z' /></svg>;
export function AddSvg() { return _Add; }
const _ArrowDownward = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z' /></svg>;
export function AnimationSvg() { return _Animation; }
const _Animation = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M15 2c-2.71 0-5.05 1.54-6.22 3.78-1.28.67-2.34 1.72-3 3C3.54 9.95 2 12.29 2 15c0 3.87 3.13 7 7 7 2.71 0 5.05-1.54 6.22-3.78 1.28-.67 2.34-1.72 3-3C20.46 14.05 22 11.71 22 9c0-3.87-3.13-7-7-7M9 20c-2.76 0-5-2.24-5-5 0-1.12.37-2.16 1-3 0 3.87 3.13 7 7 7-.84.63-1.88 1-3 1m3-3c-2.76 0-5-2.24-5-5 0-1.12.37-2.16 1-3 0 3.86 3.13 6.99 7 7-.84.63-1.88 1-3 1m4.7-3.3c-.53.19-1.1.3-1.7.3-2.76 0-5-2.24-5-5 0-.6.11-1.17.3-1.7.53-.19 1.1-.3 1.7-.3 2.76 0 5 2.24 5 5 0 .6-.11 1.17-.3 1.7M19 12c0-3.86-3.13-6.99-7-7 .84-.63 1.87-1 3-1 2.76 0 5 2.24 5 5 0 1.12-.37 2.16-1 3' /></svg>;
export function ArrowDownwardSvg() { return _ArrowDownward; }
const _ArrowDropDown = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M7 10l5 5 5-5z' /></svg>;
export function ArrowDropDownSvg() { return _ArrowDropDown; }

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

@@ -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 David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -18,7 +18,8 @@ export class Slider extends React.Component<{
onChange: (v: number) => void,
onChangeImmediate?: (v: number) => void,
disabled?: boolean,
onEnter?: () => void
onEnter?: () => void,
hideInput?: boolean
}, { isChanging: boolean, current: number }> {
state = { isChanging: false, current: 0 };
@@ -69,7 +70,7 @@ export class Slider extends React.Component<{
render() {
let step = this.props.step;
if (step === void 0) step = 1;
return <div className='msp-slider'>
return <div className={!this.props.hideInput ? 'msp-slider' : 'msp-slider msp-slider-no-input'}>
<div>
<SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
onBeforeChange={this.begin}

View File

@@ -133,6 +133,16 @@
bottom: 0;
}
&-no-input {
> div:first-child {
right: 18px;
}
> div:last-child {
visibility: hidden;
width: 0;
}
}
input[type=text] {
padding-right: 6px;
padding-left: 4px;

View File

@@ -454,6 +454,17 @@
}
}
.msp-state-snapshot-animation-slider {
position: relative;
display: inline-block;
width: 120px;
line-height: $row-height;
}
.msp-state-snapshot-animation-button {
margin-left: $control-spacing;
}
.msp-animation-viewport-controls {
line-height: $row-height;
float: left;
@@ -475,6 +486,7 @@
left: 0;
margin-top: $control-spacing;
background: $control-background;
z-index: 10001;
.msp-control-row:first-child {
margin-top: 0;

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

@@ -11,7 +11,16 @@ import { PluginAnimationManager } from '../mol-plugin-state/manager/animation';
import { isTimingMode } from '../mol-util/debug';
import { printTimerResults } from '../mol-gl/webgl/timer';
const MaxProperFrameDelta = 1000 / 30;
export class PluginAnimationLoop {
private lastTickT: number = 0;
// Proper time is used to prevent animations from skipping
// if there is a blocking operation, e.g., shader compilation
// The drawback of this is that sometimes the animation will take
// longer than intended, but hopefully that's a reasonable tradeoff
private properTimeT: number = 0;
private currentFrame: any = void 0;
private _isAnimating = false;
@@ -34,21 +43,26 @@ export class PluginAnimationLoop {
}
private frame = () => {
this.tick(now());
const t = now();
const dt = t - this.lastTickT;
this.lastTickT = t;
this.properTimeT += Math.min(dt, MaxProperFrameDelta);
this.tick(this.properTimeT);
if (this._isAnimating) {
this.currentFrame = requestAnimationFrame(this.frame);
}
};
resetTime(t: number = now()) {
resetTime(t: number) {
this.plugin.canvas3d?.resetTime(t);
}
start(options?: { immediate?: boolean }) {
this.plugin.canvas3d?.resume();
this._isAnimating = true;
this.resetTime();
// TODO: should immediate be the default mode?
this.resetTime(0);
this.properTimeT = 0;
this.lastTickT = now();
if (options?.immediate) this.frame();
else this.currentFrame = requestAnimationFrame(this.frame);
}

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

@@ -9,7 +9,7 @@ import { PartialCanvas3DProps } from '../mol-canvas3d/canvas3d';
import { AnimateAssemblyUnwind } from '../mol-plugin-state/animation/built-in/assembly-unwind';
import { AnimateCameraSpin } from '../mol-plugin-state/animation/built-in/camera-spin';
import { AnimateModelIndex } from '../mol-plugin-state/animation/built-in/model-index';
import { AnimateStateSnapshots } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { AnimateStateSnapshotTransition, AnimateStateSnapshots } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { PluginStateAnimation } from '../mol-plugin-state/animation/model';
import { DataFormatProvider } from '../mol-plugin-state/formats/provider';
import { StateAction, StateTransformer } from '../mol-state';
@@ -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),
@@ -139,6 +140,7 @@ export const DefaultPluginSpec = (): PluginSpec => ({
AnimateCameraSpin,
AnimateCameraRock,
AnimateStateSnapshots,
AnimateStateSnapshotTransition,
AnimateAssemblyUnwind,
AnimateStructureSpin,
AnimateStateInterpolation

View File

@@ -1,12 +1,12 @@
/**
* 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 David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @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';
@@ -25,6 +25,9 @@ import { PluginBehavior } from './behavior';
import { PluginCommands } from './commands';
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 };
@@ -116,8 +119,43 @@ 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;
}
if (snapshot.transition?.autoplay) {
await Scheduler.immediatePromise();
this.animation.play(AnimateStateSnapshotTransition, {});
}
}
async setAnimationSnapshot(snapshot: PluginState.Snapshot, frameIndex: number) {
await this.animation.stopStateTransitionAnimation();
const { transition } = snapshot;
if (!transition) return;
const finalIndex = Math.min(frameIndex, transition.frames.length - 1);
const frame = transition.frames[finalIndex] ?? snapshot.data;
if (frame.data) await this.plugin.runTask(this.data.setSnapshot(frame.data));
if (frame.canvas3d?.props) {
const settings = PD.normalizeParams(Canvas3DParams, frame.canvas3d.props, 'children');
this.plugin.canvas3d?.setProps(settings);
}
if (frame.camera?.current) {
PluginCommands.Camera.Reset(this.plugin, {
snapshot: frame.camera.current,
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);
}
}
@@ -211,7 +249,78 @@ namespace PluginState {
structureComponentManager?: {
options?: StructureComponentManager.Options
},
durationInMs?: number
durationInMs?: number,
transition?: StateTransition,
onLoadMarkdownCommands?: Record<string, any>
}
export interface StateTransition {
autoplay?: boolean,
loop?: boolean,
frames: {
durationInMs: number,
data: State.Snapshot,
camera?: Snapshot['camera'],
canvas3d?: { props?: Canvas3DProps },
}[],
}
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;
for (let i = 0; i < transition.frames.length; i++) {
const frame = transition.frames[i];
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;
if (!transition) return undefined;
let t = timestamp;
if (transition.loop) {
t %= getStateTransitionDuration(snapshot) ?? 1;
}
let currentDuration = 0;
for (let i = 0; i < transition.frames.length; i++) {
if (currentDuration >= t) return i;
const frame = transition.frames[i];
currentDuration += frame.durationInMs;
}
return transition.frames.length - 1;
}
export type SnapshotType = 'json' | 'molj' | 'zip' | 'molx'

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

@@ -2,6 +2,7 @@
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
*/
import { ParamDefinition as PD } from '../../mol-util/param-definition';
@@ -26,9 +27,11 @@ import { addSphere } from '../../mol-geo/geometry/mesh/builder/sphere';
import { sphereVertexCount } from '../../mol-geo/primitive/sphere';
import { Points } from '../../mol-geo/geometry/points/points';
import { PointsBuilder } from '../../mol-geo/geometry/points/points-builder';
import { Mat4 } from '../../mol-math/linear-algebra';
export const VolumeDotParams = {
isoValue: Volume.IsoValueParam,
perturbPositions: PD.Boolean(false)
};
export type VolumeDotParams = typeof VolumeDotParams
export type VolumeDotProps = PD.Values<VolumeDotParams>
@@ -58,9 +61,10 @@ export function VolumeSphereImpostorVisual(materialId: number): VolumeVisual<Vol
createLocationIterator: createVolumeCellLocationIterator,
getLoci: getDotLoci,
eachLocation: eachDot,
setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>) => {
setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>, newTheme: Theme, currentTheme: Theme) => {
state.createGeometry = (
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats)
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats) ||
newProps.perturbPositions !== currentProps.perturbPositions
);
},
geometryUtils: Spheres.Utils,
@@ -77,9 +81,10 @@ export function VolumeSphereMeshVisual(materialId: number): VolumeVisual<VolumeS
createLocationIterator: createVolumeCellLocationIterator,
getLoci: getDotLoci,
eachLocation: eachDot,
setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>) => {
setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>, newTheme: Theme, currentTheme: Theme) => {
state.createGeometry = (
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats) ||
newProps.perturbPositions !== currentProps.perturbPositions ||
newProps.sizeFactor !== currentProps.sizeFactor ||
newProps.detail !== currentProps.detail
);
@@ -91,6 +96,26 @@ export function VolumeSphereMeshVisual(materialId: number): VolumeVisual<VolumeS
}, materialId);
}
type Basis = { x: Vec3, y: Vec3, z: Vec3, maxScale: number }
function getBasis(m: Mat4): Basis {
return {
...Mat4.extractBasis(m),
maxScale: Mat4.getMaxScaleOnAxis(m)
};
}
const offset = Vec3();
function getRandomOffsetFromBasis({ x, y, z, maxScale }: Basis): Vec3 {
const rx = (Math.random() - 0.5) * maxScale;
const ry = (Math.random() - 0.5) * maxScale;
const rz = (Math.random() - 0.5) * maxScale;
Vec3.scale(offset, x, rx);
Vec3.scaleAndAdd(offset, offset, y, ry);
Vec3.scaleAndAdd(offset, offset, z, rz);
return offset;
}
export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeSphereProps, spheres?: Spheres): Spheres {
const { cells: { space, data }, stats } = volume.grid;
@@ -103,14 +128,28 @@ export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, k
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const invert = isoVal < 0;
// Precompute basis vectors and largest cell axis length
const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined;
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
if (space.get(data, x, y, z) < isoVal) continue;
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) continue;
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
builder.add(p[0], p[1], p[2], space.dataOffset(x, y, z));
const cellIdx = space.dataOffset(x, y, z);
if (basis) {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const offset = getRandomOffsetFromBasis(basis);
Vec3.add(p, p, offset);
} else {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
}
builder.add(p[0], p[1], p[2], cellIdx);
}
}
}
@@ -130,24 +169,37 @@ export function createVolumeSphereMesh(ctx: VisualContext, volume: Volume, key:
const p = Vec3();
const [xn, yn, zn] = space.dimensions;
const count = (xn * yn * zn) / 10;
const count = Math.ceil((xn * yn * zn) / 10);
const vertexCount = count * sphereVertexCount(detail);
const builderState = MeshBuilder.createState(vertexCount, vertexCount / 2, mesh);
const builderState = MeshBuilder.createState(vertexCount, Math.ceil(vertexCount / 2), mesh);
const l = Volume.Cell.Location(volume);
const themeSize = theme.size.size;
const invert = isoVal < 0;
// Precompute basis vectors and largest cell axis length
const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined;
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
if (space.get(data, x, y, z) < isoVal) continue;
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) continue;
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
builderState.currentGroup = space.dataOffset(x, y, z);
l.cell = builderState.currentGroup as Volume.CellIndex;
const size = themeSize(l);
addSphere(builderState, p, size * sizeFactor, detail);
const cellIdx = space.dataOffset(x, y, z);
l.cell = cellIdx as Volume.CellIndex;
const size = themeSize(l) * sizeFactor;
if (basis) {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const offset = getRandomOffsetFromBasis(basis);
Vec3.add(p, p, offset);
} else {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
}
builderState.currentGroup = cellIdx;
addSphere(builderState, p, size, detail);
}
}
}
@@ -173,9 +225,10 @@ export function VolumePointVisual(materialId: number): VolumeVisual<VolumePointP
createLocationIterator: createVolumeCellLocationIterator,
getLoci: getDotLoci,
eachLocation: eachDot,
setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<VolumePointParams>, currentProps: PD.Values<VolumePointParams>) => {
setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<VolumePointParams>, currentProps: PD.Values<VolumePointParams>, newTheme: Theme, currentTheme: Theme) => {
state.createGeometry = (
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats)
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats) ||
newProps.perturbPositions !== currentProps.perturbPositions
);
},
geometryUtils: Points.Utils,
@@ -193,14 +246,28 @@ export function createVolumePoint(ctx: VisualContext, volume: Volume, key: numbe
const count = Math.ceil((xn * yn * zn) / 10);
const builder = PointsBuilder.create(count, Math.ceil(count / 2), points);
const invert = isoVal < 0;
// Precompute basis vectors and largest cell axis length
const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined;
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
if (space.get(data, x, y, z) < isoVal) continue;
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) continue;
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
builder.add(p[0], p[1], p[2], space.dataOffset(x, y, z));
const cellIdx = space.dataOffset(x, y, z);
if (basis) {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const offset = getRandomOffsetFromBasis(basis);
Vec3.add(p, p, offset);
} else {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
}
builder.add(p[0], p[1], p[2], cellIdx);
}
}
}

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

@@ -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 David Sehnal <david.sehnal@gmail.com>
* @author Adam Midlik <midlik@gmail.com>
@@ -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 };
@@ -41,9 +41,34 @@ namespace StateBuilder {
| { kind: 'delete', ref: string }
| { kind: 'insert', ref: string, transform: StateTransform }
function buildTree(state: BuildState) {
function getAffectedRefs(state: BuildState): string[] {
const refs = new Set<string>();
for (const a of state.actions) {
switch (a.kind) {
case 'add': refs.add(a.transform.ref); break;
case 'update': refs.add(a.ref); break;
case 'delete': refs.add(a.ref); break;
case 'insert': {
refs.add(a.ref);
refs.add(a.transform.ref);
const children = state.tree.children.get(a.ref).toArray();
for (const c of children) {
refs.add(c);
}
break;
}
}
}
return Array.from(refs);
}
function buildTree(state: BuildState, options?: { useHashVersion?: boolean }) {
if (!state.state || state.state.tree === state.editInfo.sourceTree) {
return state.tree.asImmutable();
const ret = state.tree.asImmutable();
if (options?.useHashVersion) {
StateTree.setParamHashVersion(ret, getAffectedRefs(state));
}
return ret;
}
// The tree has changed in the meantime, we need to reapply the changes!
@@ -64,7 +89,11 @@ namespace StateBuilder {
}
}
state.editInfo.sourceTree = state.tree;
return tree.asImmutable();
const ret = tree.asImmutable();
if (options?.useHashVersion) {
StateTree.setParamHashVersion(ret, getAffectedRefs(state));
}
return ret;
}
export function is(obj: any): obj is StateBuilder {
@@ -103,7 +132,7 @@ namespace StateBuilder {
this.state.actions.push({ kind: 'delete', ref });
return this;
}
getTree(): StateTree { return buildTree(this.state); }
getTree(options?: { useHashVersion?: boolean }): StateTree { return buildTree(this.state, options); }
commit(options?: Partial<State.UpdateOptions>) {
if (!this.state.state) throw new Error('Cannot commit template tree');
@@ -287,7 +316,7 @@ namespace StateBuilder {
toRoot<A extends StateObject>() { return this.root.toRoot<A>(); }
delete(ref: StateObjectRef) { return this.root.delete(ref); }
getTree(): StateTree { return buildTree(this.state); }
getTree(options?: { useHashVersion?: boolean }): StateTree { return buildTree(this.state, options); }
/** Returns selector to this node. */
commit(options?: Partial<State.UpdateOptions>): Promise<StateObjectSelector<A>> {

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

@@ -1,11 +1,12 @@
/**
* 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>
*/
import { StateTransformer } from './transformer';
import { UUID } from '../mol-util';
import { hashMurmur128o } from '../mol-data/util';
export { Transform as StateTransform };
@@ -169,6 +170,21 @@ namespace Transform {
return true;
}
const _emptyParams = {};
/** Updates the version of the transform to be computed as hash of the parameters */
export function setParamsHashVersion(t: Transform) {
let version: string;
try {
version = hashMurmur128o(t.params ?? _emptyParams);
} catch {
const pToJson = t.transformer.definition.customSerialization
? t.transformer.definition.customSerialization.toJSON
: _id;
version = hashMurmur128o(pToJson(t.params ?? _emptyParams));
}
(t as { version: string }).version = version;
}
export interface Serialized {
parent: string,
transformer: string,

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>
*/
@@ -229,4 +229,25 @@ namespace StateTree {
if (child?.transformer.definition.isDecorator) return getDecoratorRoot(tree, child.ref);
return ref;
}
export function setParamHashVersion(tree: StateTree, refs: StateTransform.Ref[]) {
for (const ref of refs) {
const transform = tree.transforms.get(ref);
if (transform) {
StateTransform.setParamsHashVersion(transform);
}
}
}
/** Re-use parameters of transforms with the same ref, transformer, and version */
export function reuseTransformParams(destination: StateTree.Serialized, source: StateTree.Serialized) {
const srcMap = new Map<StateTransform.Ref, StateTransform.Serialized>(source.transforms.map(t => [t.ref, t]));
for (const dest of destination.transforms) {
const src = srcMap.get(dest.ref);
if (!src) continue;
if (dest.transformer !== src.transformer || dest.version !== src.version) continue;
dest.params = src.params;
}
}
}

View File

@@ -2,6 +2,7 @@
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
*/
import type { SizeTheme } from '../size';
@@ -11,7 +12,7 @@ import { LocationSize } from '../../mol-geo/geometry/size-data';
import { Volume } from '../../mol-model/volume/volume';
import { Location } from '../../mol-model/location';
const Description = 'Assign size based on the given value of a volume cell.';
const Description = 'Assign size based on the given value of a volume cell. Negative values are made positive.';
export const VolumeValueSizeThemeParams = {
scale: PD.Numeric(1, { min: 0.1, max: 5, step: 0.1 }),
@@ -28,7 +29,7 @@ export function VolumeValueSizeTheme(ctx: ThemeDataContext, props: PD.Values<Vol
const isLocation = Volume.Cell.isLocation;
const size: LocationSize = (location: Location): number => {
if (isLocation(location)) {
return data[location.cell] * props.scale;
return Math.abs(data[location.cell]) * props.scale;
} else {
return 0;
}

View File

@@ -8,6 +8,7 @@ import { NumberArray } from '../../mol-util/type-helpers';
import { Vec3 } from '../../mol-math/linear-algebra';
import { Hcl } from './spaces/hcl';
import { Lab } from './spaces/lab';
import { Hsl } from './spaces/hsl';
/** RGB color triplet expressed as a single number */
export type Color = { readonly '@type': 'color' } & number
@@ -116,6 +117,17 @@ export namespace Color {
return ((r << 16) | (g << 8) | b) as Color;
}
const _interpolateHsl1 = Hsl.zero();
const _interpolateHsl2 = Hsl.zero();
/** Linear interpolation between two colors in HSL space */
export function interpolateHsl(c1: Color, c2: Color, t: number): Color {
const hsl1 = Hsl.fromColor(_interpolateHsl1, c1);
const hsl2 = Hsl.fromColor(_interpolateHsl2, c2);
Hsl.interpolate(hsl1, hsl1, hsl2, t);
return Hsl.toColor(hsl1);
}
export function hasHue(c: Color): boolean {
const r = c >> 16 & 255;
const g = c >> 8 & 255;

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*
* Color conversion and interpolation code adapted from chroma.js (https://github.com/gka/chroma.js)
* Copyright (c) 2011-2018, Gregor Aisch, BSD license
*/
import type { Color } from '../color';
import { Rgb } from './rgb';
export { Hsl };
interface Hsl extends Array<number> { [d: number]: number, '@type': 'hsl', length: 3 }
function Hsl() {
return Hsl.zero();
}
namespace Hsl {
export function zero(): Hsl {
const out = [0.1, 0.0, 0.0];
out[0] = 0;
return out as Hsl;
}
const _rgb = Rgb();
export function fromColor(out: Hsl, color: Color) {
Rgb.fromColor(_rgb, color);
return Hsl.fromRgb(out, _rgb);
}
export function toColor(hsl: Hsl): Color {
toRgb(_rgb, hsl);
return Rgb.toColor(_rgb);
}
export function fromRgb(out: Hsl, rgb: Rgb) {
const [r, g, b] = rgb;
const minRgb = Math.min(r, g, b);
const maxRgb = Math.max(r, g, b);
const l = (maxRgb + minRgb) / 2;
let s: number = 0, h: number = 0;
if (maxRgb === minRgb) {
s = 0;
h = Number.NaN;
} else {
s = l < 0.5
? (maxRgb - minRgb) / (maxRgb + minRgb)
: (maxRgb - minRgb) / (2 - maxRgb - minRgb);
}
if (r === maxRgb) h = (g - b) / (maxRgb - minRgb);
else if (g === maxRgb) h = 2 + (b - r) / (maxRgb - minRgb);
else if (b === maxRgb) h = 4 + (r - g) / (maxRgb - minRgb);
h *= 60;
if (h < 0) h += 360;
out[0] = h;
out[1] = s;
out[2] = l;
return out;
}
const _t3 = [0, 0, 0];
const _c = [0, 0, 0];
export function toRgb(out: Rgb, hsl: Hsl) {
const [h, s, l] = hsl;
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const t3 = _t3;
const c = _c;
const t2 = l < 0.5 ? l * (1 + s) : l + s - l * s;
const t1 = 2 * l - t2;
const h_ = h / 360;
t3[0] = h_ + 1 / 3;
t3[1] = h_;
t3[2] = h_ - 1 / 3;
for (let i = 0; i < 3; i++) {
if (t3[i] < 0) t3[i] += 1;
if (t3[i] > 1) t3[i] -= 1;
if (6 * t3[i] < 1) c[i] = t1 + (t2 - t1) * 6 * t3[i];
else if (2 * t3[i] < 1) c[i] = t2;
else if (3 * t3[i] < 2) c[i] = t1 + (t2 - t1) * (2 / 3 - t3[i]) * 6;
else c[i] = t1;
}
r = c[0];
g = c[1];
b = c[2];
}
out[0] = r;
out[1] = g;
out[2] = b;
return out;
}
export function interpolate(out: Hsl, col1: Hsl, col2: Hsl, t: number) {
const xyz0 = col1, xyz1 = col2;
const [hue0, sat0, lbv0] = xyz0;
const [hue1, sat1, lbv1] = xyz1;
let sat, hue, dh;
if (!isNaN(hue0) && !isNaN(hue1)) {
// both colors have hue
if (hue1 > hue0 && hue1 - hue0 > 180) {
dh = hue1 - (hue0 + 360);
} else if (hue1 < hue0 && hue0 - hue1 > 180) {
dh = hue1 + 360 - hue0;
} else {
dh = hue1 - hue0;
}
hue = hue0 + t * dh;
} else if (!isNaN(hue0)) {
hue = hue0;
if ((lbv1 === 1 || lbv1 === 0)) sat = sat0;
} else if (!isNaN(hue1)) {
hue = hue1;
if ((lbv0 === 1 || lbv0 === 0)) sat = sat1;
} else {
hue = Number.NaN;
}
if (sat === undefined) sat = sat0 + t * (sat1 - sat0);
const lbv = lbv0 + t * (lbv1 - lbv0);
out[0] = hue;
out[1] = sat;
out[2] = lbv;
return out;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import type { Color } from '../color';
export { Rgb };
interface Rgb extends Array<number> { [d: number]: number, '@type': 'normalized-rgb', length: 3 }
function Rgb() {
return Rgb.zero();
}
namespace Rgb {
export function zero(): Rgb {
const out = [0.1, 0.0, 0.0];
out[0] = 0;
return out as Rgb;
}
export function fromColor(out: Rgb, hexColor: Color) {
out[0] = (hexColor >> 16 & 255) / 255;
out[1] = (hexColor >> 8 & 255) / 255;
out[2] = (hexColor & 255) / 255;
return out;
}
export function toColor(rgb: Rgb): Color {
return (((rgb[0] * 255) << 16) | ((rgb[1] * 255) << 8) | (rgb[2] * 255)) as Color;
}
}

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> {

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

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

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