Compare commits

...

72 Commits

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

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

* SequenceColor extension

* SequenceColor extension - forceUpdate when custom prop changes

* SequenceColor extension - proper caching

* Update CHANGELOG

* SequenceColor extension - registry

* SequenceColor extension - refactor

* SequenceColor extension - minor changes

* SequenceColor extension - switch to experimentalSequenceColorTheme

* SequenceColor extension - ensureCustomProperties

* SequenceColor extension - clean

* SequenceColor extension - avoid repeated allocation for Location

* SequenceColor extension - memoizeLatest, but wrong

* SequenceColor extension - memoizeLatest fixed

* SequenceColor extension - remove unnecessary loci caching

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

* volume server: default to P1

* tweaks

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

* print validation errors to plugin log

* fixes

* background postprocessing

* fix resetCanvasProps

* fix link

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

* Fading transparent outlines

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

* fixes

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

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

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

* bugfixes & tweaks

* linting

* support "discrete" scalar transform

* tweak audio path

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

* add some TODO comments

* separate audio as mp3. Do coloring with DG scheme

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

* salt bridge.

* better coloring

* support for entry-id test in MolScriptBuilder.

* lint

* update audio, sync animation

* cleanup

* clean up and sync audio/anim

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

* trigger markdown commands from MVS primitives

* docs

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

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

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

* refactoring

* support lammpstrj

* tweaks

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

* Add createMVSX

* mvs: support trackball animation

* tweak

* more fine grained speed of camera spin animation

* update TrackballControlsParams.animate.spin.speed

* story-session-url arg support
2025-08-15 13:27:34 +02:00
giagitom
88cc720dd2 fix render without postprocessing 2025-08-14 23:34:45 +02:00
giagitom
201433cc91 Lint fix 2025-08-14 19:53:36 +02:00
giagitom
8582303491 Add Monolayer transparency (exploiting dpoit) 2025-08-14 19:40:24 +02:00
dsehnal
655c3edadd 5.0.0-dev.6 2025-08-14 11:37:40 +02:00
David Sehnal
a4323a4bd8 MVS animation improvements (#1608) 2025-08-14 11:33:50 +02:00
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
dsehnal
0dc05e1138 5.0.0-dev.3 2025-08-02 18:21:14 +02:00
David Sehnal
dd11cacae4 Markdown Commands and MVS improvements (#1597)
* add query command to markdown extensions

* fix typo

* better postprocessing param support in MVS

* molstar_mesh/label/line_params
2025-08-02 18:19:01 +02:00
David Sehnal
b503259758 another io-ts import fix (#1595) 2025-08-01 06:28:26 +02:00
zachcp
1e98741e16 Update field-schema.ts (#1594) 2025-08-01 05:38:41 +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
134 changed files with 5478 additions and 646 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,24 +21,40 @@ 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`,
- Canvas node: support custom properties `molstar_enable_outline`, `molstar_enable_shadow`, `molstar_enable_ssao`
- `representation` node: support custom property `molstar_representation_params`
- Add `backbone` and `line` representation types
- `primitives` node: support custom property `molstar_mesh/label/line_params`
- `canvas` node: support custom property `molstar_postprocessing` with the ability to customize outline, depth of field, bloom, shadow, occlusion (SSAO), fog, and background
- `clip` node support for structure and volume representations
- `grid_slice` representation support for volumes
- Support tethers and background for primitive labels
- 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
- Print tree validation errors to plugin log
- Added new color schemes, synchronized with D3.js ('inferno', 'magma', 'turbo', 'rainbow', 'sinebow', 'warm', 'cool', 'cubehelix-default', 'category-10', 'observable-10', 'tableau-10')
- Snapshot Markdown improvements
- Add `MarkdownExtensionManager` (`PluginContext.managers.markdownExtensions`)
- Support custom markdown commands to control the plugin via the `[link](!command)` pattern
- Support rendering custom elements via the `![alt](!parameters)` pattern
- Support tables
- Support loading images from MVSX files
- Support loading images and audio from MVSX files
- Indicate external links with ⤴
- Audio support
- Add `PluginState.Snapshot.onLoadMarkdownCommands`
- Avoid calculating rings for coarse-grained structures
- Fix isosurface compute shader normals when transformation matrix is applied to volume
- Symmetry operator naming for spacegroup symmetry - parenthesize multi-character indices (1_111-1 -> 1_(11)1(-1))
@@ -52,7 +70,10 @@ Note that since we don't clearly distinguish between a public and private interf
- Add `StructureInstances` transform
- `mvs-stories` app
- Add `story-id` URL arg support
- Add `story-session-url` URL arg support
- Add "Download MVS State" link
- Add "Open in Mol*" link
- Add "Edit in MolViewStories" link for story states
- Add ray-based picking
- Render narrow view of scene scene from ray origin & direction to a few pixel sized viewport
- Cast ray on every input as opposed to the standard "whole screen" picking
@@ -62,6 +83,28 @@ 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`
- Fix renderer transparency check
- VolumeServer & "VolumeCIF": default to P 1 spacegroup
- Fix `ColorScale` for continuous case without offsets (broke in v4.13.0)
- Experimental: support for custom color themes in Sequence Panel
## [v4.18.0] - 2025-06-08
- MolViewSpec extension:

View File

@@ -14,12 +14,25 @@ The main use case of this is enriching [MolViewSpec](`https://molstar.org/mol-vi
Extends Markdown Hyperlink syntax to support expressions of the form `[title](!c1=v1&c2=v2&...)` into an executable command. The command can be executed either on click, mouse enter, or mouse leave.
Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `encodeURIComponent`, in Python `urllib.parse.quote_plus/urlencode`).
### Built-in Commands
- `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=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
@@ -28,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 provivided 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.2",
"version": "5.0.0-dev.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "molstar",
"version": "5.0.0-dev.2",
"version": "5.0.0-dev.13",
"license": "MIT",
"dependencies": {
"@types/argparse": "^2.0.17",
@@ -21,9 +21,9 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"h264-mp4-encoder": "^1.0.12",
"immer": "^10.1.1",
"immutable": "^5.1.2",
"io-ts": "^2.2.22",
"mutative": "^1.2.0",
"node-fetch": "^2.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -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.2",
"version": "5.0.0-dev.13",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {
@@ -121,7 +121,9 @@
"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>",
"Victoria Doshchenko <doshchenko.victoria@gmail.com>"
],
"license": "MIT",
"devDependencies": {
@@ -165,9 +167,9 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"h264-mp4-encoder": "^1.0.12",
"immer": "^10.1.1",
"immutable": "^5.1.2",
"io-ts": "^2.2.22",
"mutative": "^1.2.0",
"node-fetch": "^2.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,407 @@
/**
* 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, repr] = polymer(_1cbs, { color: Colors['1cbs'] });
repr.colorFromSource({
ref: 'residue_colors',
schema: 'residue',
category_name: 'atom_site',
field_name: 'label_comp_id',
palette: {
kind: 'categorical',
missing_color: 'white',
colors: {
ALA: 'red',
ILE: 'white',
LYS: 'white',
}
}
});
const surface = poly.representation({
type: 'surface',
surface_type: 'gaussian',
}).opacity({ opacity: 0.33 });
_1cbs.component({ selector: 'ligand' })
.transform({
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'],
});
anim.interpolate({
kind: 'color',
target_ref: 'residue_colors',
duration_ms: 2000,
property: ['palette', 'colors'],
start: {
ALA: 'yellow',
},
end: {
ILE: 'blue',
LYS: 'purple',
},
});
return builder;
},
camera: {
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' });
if (options?.color) {
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,
background: {
name: 'horizontalGradient',
params: {
topColor: 0x777777,
bottomColor: 0xffffff,
}
},
// Example with background image:
// background: {
// name: 'image',
// params: {
// // URL can also be filename in MVSX archive
// source: { name: 'url', params: 'URL' }
// }
// }
}
}
});
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;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,7 +6,11 @@
*/
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 { BackgroundParams } from '../../mol-canvas3d/passes/background';
import { BloomParams } from '../../mol-canvas3d/passes/bloom';
import { DofParams } from '../../mol-canvas3d/passes/dof';
import { OutlineParams } from '../../mol-canvas3d/passes/outline';
import { ShadowParams } from '../../mol-canvas3d/passes/shadow';
import { SsaoParams } from '../../mol-canvas3d/passes/ssao';
@@ -18,10 +22,12 @@ import { PluginState } from '../../mol-plugin/state';
import { StateObjectSelector } from '../../mol-state';
import { fovAdjustedPosition } from '../../mol-util/camera';
import { ColorNames } from '../../mol-util/color/names';
import { deepClone } from '../../mol-util/object';
import { ParamDefinition } from '../../mol-util/param-definition';
import { decodeColor } from './helpers/utils';
import { MolstarLoadingContext } from './load';
import { SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { MolstarNode, MolstarNodeParams } from './tree/molstar/molstar-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
@@ -119,37 +125,102 @@ 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;
}
function normalizeBackground(variant: any, prev: any): any {
if (!variant) return prev;
return ParamDefinition.normalizeParams(BackgroundParams, { variant }, 'children');
}
/** Create a deep copy of `oldCanvasProps` with values modified according to a canvas node params. */
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, custom?: Record<string, any>): Canvas3DProps {
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, animationNode: MVSAnimationNode<'animation'> | undefined): Canvas3DProps {
const params = canvasNode?.params;
const backgroundColor = decodeColor(params?.background_color) ?? DefaultCanvasBackgroundColor;
const outline = !!canvasNode?.custom?.molstar_enable_outline;
const shadow = !!canvasNode?.custom?.molstar_enable_shadow;
const occlusion = !!canvasNode?.custom?.molstar_enable_ssao;
const molstar_postprocessing = canvasNode?.custom?.molstar_postprocessing;
const outline = molstar_postprocessing?.enable_outline;
const outlineParams = molstar_postprocessing?.outline_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 background = molstar_postprocessing?.background;
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) }
: oldCanvasProps.postprocessing.outline,
shadow: shadow
? { name: 'on', params: ParamDefinition.getDefaultValues(ShadowParams) }
: oldCanvasProps.postprocessing.shadow,
occlusion: occlusion
? { name: 'on', params: ParamDefinition.getDefaultValues(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),
background: normalizeBackground(background, oldCanvasProps.postprocessing.background),
},
cameraFog: optionalParams(fog, fogParams, CameraFogParams, oldCanvasProps.cameraFog),
renderer: {
...oldCanvasProps.renderer,
backgroundColor: backgroundColor,
},
trackball: {
...oldCanvasProps?.trackball,
...(trackballAnimationName
? {
animate: {
name: trackballAnimationName,
params: {
...TrackballControlsParams.animate.map(trackballAnimationName)?.defaultValue,
...trackballAnimationParams
}
}
}
: {}
),
}
};
}
export function resetCanvasProps(plugin: PluginContext) {
const old = plugin.canvas3d?.props;
plugin.canvas3d?.setProps({
...old,
postprocessing: {
...old,
outline: deepClone(DefaultCanvas3DParams.postprocessing.outline),
shadow: deepClone(DefaultCanvas3DParams.postprocessing.shadow),
occlusion: deepClone(DefaultCanvas3DParams.postprocessing.occlusion),
dof: deepClone(DefaultCanvas3DParams.postprocessing.dof),
bloom: deepClone(DefaultCanvas3DParams.postprocessing.bloom),
background: deepClone(DefaultCanvas3DParams.postprocessing.background),
},
cameraFog: deepClone(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,17 +149,20 @@ 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') {
if (!hasPrimitiveKind(a.data, 'mesh')) return StateObject.Null;
const customMeshParams = a.data.node.custom?.molstar_mesh_params;
return new SO.Shape.Provider({
label,
data: context,
params: {
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1 }),
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1, ...customMeshParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveMesh(data, prev?.geometry),
geometryUtils: Mesh.Utils,
@@ -155,6 +172,7 @@ export const MVSBuildPrimitiveShape = MVSTransform({
const options = a.data.options;
const bgColor = options?.label_background_color;
const customLabelParams = a.data.node.custom?.molstar_label_params;
return new SO.Shape.Provider({
label,
data: context,
@@ -166,8 +184,10 @@ export const MVSBuildPrimitiveShape = MVSTransform({
tetherLength: options?.label_tether_length ?? 1,
background: isDefined(bgColor),
backgroundColor: isDefined(bgColor) ? decodeColor(bgColor) : undefined,
...customLabelParams,
}),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, props, prev: any) => buildPrimitiveLabels(data, prev?.geometry, props),
geometryUtils: Text.Utils,
@@ -175,12 +195,14 @@ export const MVSBuildPrimitiveShape = MVSTransform({
} else if (params.kind === 'lines') {
if (!hasPrimitiveKind(a.data, 'line')) return StateObject.Null;
const customLineParams = a.data.node.custom?.molstar_line_params;
return new SO.Shape.Provider({
label,
data: context,
params: {
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1 }),
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1, ...customLineParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveLines(data, prev?.geometry),
geometryUtils: Lines.Utils,
@@ -209,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 });
}
@@ -223,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 });
}
@@ -234,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,674 @@
/**
* 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 { decodeColor } from '../../../mol-util/color/utils';
import { produce } from '../../../mol-util/produce';
import { makeContinuousPaletteCheckpoints, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from '../components/annotation-color-theme';
import { palettePropsFromMVSPalette } from '../load-helpers';
import { 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,
startColor?: Color | Record<number | string, Color>,
endColor?: Color | Record<number | string, 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 startValue = transition.params.start ?? getPreviousScalarEnd(previous) ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition);
}
const endValue: any = transition.params.end;
if (time <= 0) return startValue;
else if (time >= 1 - EPSILON && !transition.params.alternate_direction && transition.params.kind !== 'color') return endValue;
let t = clamp(time, 0, 1);
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
if (transition.params.kind === 'scalar') {
return interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
return interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
return interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
if (cacheEntry.paletteFn) {
const color = cacheEntry.paletteFn(t);
return Color.toHexStyle(color);
}
const baseColors = typeof startValue === 'object' ? select(target, transition.params.property, offset) : undefined;
return interpolateColors(startValue, endValue, t, cacheEntry, baseColors);
}
}
function getPreviousMatrixEnd(previous: MVSAnimationNode<'interpolate'> | undefined, prop: 'rotation_start' | 'translation_start' | 'scale_start') {
if (!previous || previous.params.kind !== 'transform_matrix') return undefined;
return previous.params[prop];
}
const TransformState = {
pivotTranslation: Mat4(),
pivotTranslationInv: Mat4(),
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 decodeColors(color: ColorT | Record<number | string, ColorT> | undefined, baseColors: Record<number | string, ColorT> | undefined) {
if (color === undefined || color === null) return undefined;
if (typeof color === 'object') {
const ret: Record<number | string, Color> = {};
if (baseColors) {
for (const key of Object.keys(baseColors)) {
const decoded = decodeColor(baseColors[key]);
if (decoded !== undefined) {
ret[key] = decoded;
}
}
}
for (const key of Object.keys(color)) {
const decoded = decodeColor(color[key]);
if (decoded !== undefined) {
ret[key] = decoded;
}
}
return ret;
}
return decodeColor(color);
}
function interpolateColors(start: ColorT | Record<number, ColorT>, end: ColorT | Record<number, ColorT> | undefined, time: number, cacheEntry: InterpolationCacheEntry, baseColors: Record<number, ColorT> | undefined) {
const t = clamp(time, 0, 1);
if (cacheEntry.paletteFn) {
const c = cacheEntry.paletteFn(t);
return Color.toHexStyle(c);
}
if (cacheEntry.startColor === undefined) {
cacheEntry.startColor = decodeColors(start, baseColors);
}
if (cacheEntry.endColor === undefined) {
cacheEntry.endColor = decodeColors(end, undefined);
}
const { startColor, endColor } = cacheEntry;
if (typeof startColor === 'object') {
if (typeof baseColors !== 'object') {
throw new Error('Cannot interpolate from scalar color to color mapping');
}
const ret = { ...baseColors as any, ...startColor as any };
if (typeof endColor === 'object') {
for (const key of Object.keys(endColor)) {
ret[key] = Color.toHexStyle(Color.interpolate(startColor[key], endColor[key], t));
}
} else if (typeof endColor === 'number') {
for (const key of Object.keys(startColor)) {
ret[key] = Color.toHexStyle(Color.interpolate(startColor[key], endColor, t));
}
}
return ret;
}
if (typeof endColor === 'object') {
throw new Error('Cannot interpolate from scalar color to color mapping');
}
if (typeof endColor === 'number' && typeof startColor === 'number') {
return Color.toHexStyle(Color.interpolate(startColor, endColor, t));
}
return start;
}
function select(params: any, path: string | (string | number)[], offset: number) {
if (typeof path === 'string') {
return params?.[path];
}
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'>): ((value: number) => Color) | undefined {
if (props.params.kind !== 'color' || !props.params.palette) return undefined;
const params = palettePropsFromMVSPalette(props.params.palette);
if (params.name === 'discrete') return makePaletteFunctionDiscrete(params.params);
if (params.name === 'continuous') return makePaletteFunctionContinuous(params.params);
throw new Error(`NotImplementedError: makePaletteFunction for ${(props as any).name}`);
}
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 { validateTree } from './tree/generic/tree-schema';
import { MVSData, MVSData_States, Snapshot, SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode, MVSAnimationSchema } from './tree/animation/animation-tree';
import { validateTree } from './tree/generic/tree-validation';
import { convertMvsToMolstar, mvsSanityCheck } from './tree/molstar/conversion';
import { MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree, MolstarTreeSchema } from './tree/molstar/molstar-tree';
import { type MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
export interface MVSLoadOptions {
@@ -46,33 +51,54 @@ export interface MVSLoadOptions {
sanityChecks?: boolean,
/** Base for resolving relative URLs/URIs. May itself be a relative URL (relative to the window URL). */
sourceUrl?: string,
doNotReportErrors?: boolean
}
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[] = [];
for (let i = 0; i < multiData.snapshots.length; i++) {
const snapshot = multiData.snapshots[i];
const previousSnapshot = i > 0 ? multiData.snapshots[i - 1] : multiData.snapshots[multiData.snapshots.length - 1];
validateTree(MVSTreeSchema, snapshot.root, 'MVS');
validateTree(MVSTreeSchema, snapshot.root, 'MVS', plugin);
if (snapshot.animation) {
validateTree(MVSAnimationSchema, snapshot.animation, 'Animation', plugin);
}
if (options.sanityChecks) mvsSanityCheck(snapshot.root);
const molstarTree = convertMvsToMolstar(snapshot.root, options.sourceUrl);
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar', plugin);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
snapshot.root,
snapshot.animation,
{ ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms },
options
);
await assignStateTransition(ctx, plugin, entry, snapshot, options, 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 +106,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 +128,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 +233,72 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
},
parse(updateParent: UpdateTarget, node: MolstarNode<'parse'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, ParseCif, {});
} else if (format === 'pdb') {
return updateParent;
} else if (format === 'map') {
return UpdateTarget.apply(updateParent, ParseCcp4, {});
} else {
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, ParseCif, {});
case 'pdb':
case 'pdbqt':
case 'gro':
case 'xyz':
case 'mol':
case 'sdf':
case 'mol2':
case 'xtc':
case 'lammpstrj':
return updateParent;
case 'map':
return UpdateTarget.apply(updateParent, ParseCcp4, {});
default:
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
}
},
coordinates(updateParent: UpdateTarget, node: MolstarNode<'coordinates'>): UpdateTarget | undefined {
const format = node.params.format;
switch (format) {
case 'xtc':
return UpdateTarget.apply(updateParent, CoordinatesFromXtc);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, CoordinatesFromLammpstraj);
default:
console.error(`Unknown format in "coordinates" node: "${format}"`);
return undefined;
}
},
trajectory(updateParent: UpdateTarget, node: MolstarNode<'trajectory'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
} else if (format === 'pdb') {
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, {});
} else {
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
case 'pdb':
case 'pdbqt':
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, { isPdbqt: format === 'pdbqt' });
case 'gro':
return UpdateTarget.apply(updateParent, TrajectoryFromGRO);
case 'xyz':
return UpdateTarget.apply(updateParent, TrajectoryFromXYZ);
case 'mol':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL);
case 'sdf':
return UpdateTarget.apply(updateParent, TrajectoryFromSDF);
case 'mol2':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL2);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, TrajectoryFromLammpsTrajData);
default:
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
}
},
trajectory_with_coordinates(updateParent: UpdateTarget, node: MolstarNode<'trajectory_with_coordinates'>): UpdateTarget | undefined {
const result = UpdateTarget.apply(updateParent, MVSTrajectoryWithCoordinates, {
coordinatesRef: node.params.coordinates_ref,
});
return UpdateTarget.setMvsDependencies(result, [node.params.coordinates_ref]);
},
model(updateParent: UpdateTarget, node: MolstarSubtree<'model'>, context: MolstarLoadingContext): UpdateTarget {
const annotations = collectAnnotationReferences(node, context);
const model = UpdateTarget.apply(updateParent, ModelFromTrajectory, {
@@ -312,7 +421,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 { treeValidationIssues } from './tree/generic/tree-validation';
import { treeToString } from './tree/generic/tree-utils';
import { MVSAnimationSchema, MVSAnimationTree } from './tree/animation/animation-tree';
import { Root, createMVSBuilder } from './tree/mvs/mvs-builder';
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, dict } from '../generic/field-schema';
import { SimpleParamsSchema, UnionParamsSchema } from '../generic/params-schema';
import { NodeFor, ParamsOfKind, SubtreeOfKind, TreeFor, TreeSchema } from '../generic/tree-schema';
import { ColorT, ContinuousPalette, DiscretePalette, Matrix, Vector3 } from '../mvs/param-types';
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(union(nullable(ColorT), dict(union(int, str), ColorT)), null, 'Start value. If unset, parent state value is used.'),
end: OptionalField(union(nullable(ColorT), dict(union(int, str), ColorT)), null, 'End value.'),
palette: OptionalField(nullable(union(DiscretePalette, ContinuousPalette)), null, 'Palette to sample colors from. Overrides start and end values.'),
};
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

@@ -6,10 +6,8 @@
*/
import * as iots from 'io-ts';
import { PathReporter } from "io-ts/lib/PathReporter.js";
import { onelinerJsonString } from '../../../../mol-util/json';
/** All types that can be used in tree node params.
* Can be extended, this is just to list them all in one place and possibly catch some typing errors */
type AllowedValueTypes = string | number | boolean | null | [number, number, number] | string[] | number[] | {};
@@ -142,6 +140,41 @@ export function fieldValidationIssues<F extends Field>(field: F, value: any): st
if (validation._tag === 'Right') {
return undefined;
} else {
return PathReporter.report(validation);
return reportErrors(validation.left);
}
}
// Inlining `reportErrors` instead of `import { PathReporter } from 'io-ts/PathReporter'`;
// because it breaks Deno usage.
function reportErrors(errors: iots.Errors): string[] | undefined {
if (errors.length === 0) return undefined;
return errors.map(getMessage);
}
function getMessage(e: iots.ValidationError) {
return e.message !== undefined
? e.message
: `Invalid value ${stringifyError(e.value)} supplied to ${getContextPath(e.context)}`;
}
function getContextPath(context: iots.ValidationError['context']) {
return context.map(a => `${a.key}: ${a.type.name}`).join('/');
}
function getFunctionName(f: Function & { displayName?: string }) {
return f.displayName || f.name || `<function ${f.length}>`;
}
function stringifyError(v: any) {
if (typeof v === 'function') {
return getFunctionName(v);
}
if (typeof v === 'number' && !isFinite(v)) {
if (isNaN(v)) {
return 'NaN';
}
return v > 0 ? 'Infinity' : '-Infinity';
}
return JSON.stringify(v);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -15,6 +15,11 @@ const Cartoon = {
tubular_helices: OptionalField(bool, false, 'Simplify corkscrew helices to tubes.'),
};
const Backbone = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
};
const BallAndStick = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
@@ -22,6 +27,13 @@ const BallAndStick = {
ignore_hydrogens: OptionalField(bool, false, 'Controls whether hydrogen atoms are drawn.'),
};
const Line = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
/** Controls whether hydrogen atoms are drawn. */
ignore_hydrogens: OptionalField(bool, false, 'Controls whether hydrogen atoms are drawn.'),
};
const Spacefill = {
/** Scales the corresponding visuals */
size_factor: OptionalField(float, 1, 'Scales the corresponding visuals.'),
@@ -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

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

View File

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

View File

@@ -106,6 +106,7 @@ export class DrawPass {
this.depthTextureOpaque.define(width, height);
}
// TODO: link programs in parallel
this.wboit = new WboitPass(webgl, width, height);
this.dpoit = new DpoitPass(webgl, width, height);
this.marking = new MarkingPass(webgl, width, height);
@@ -198,8 +199,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

@@ -23,6 +23,12 @@ export class CommitQueue {
return this.removeMap.size + this.addMap.size;
}
forEachAdd(cb: (o: GraphicsRenderObject) => void) {
for (let n = this.addList.first; n; n = n.next) {
cb(n.value);
}
}
add(o: GraphicsRenderObject) {
if (this.removeMap.has(o)) {
const a = this.removeMap.get(o)!;

View File

@@ -8,14 +8,14 @@ import { RenderableState, Renderable } from './renderable';
import { idFactory } from '../mol-util/id-factory';
import { WebGLContext } from './webgl/context';
import { DirectVolumeValues, DirectVolumeRenderable } from './renderable/direct-volume';
import { MeshValues, MeshRenderable } from './renderable/mesh';
import { MeshValues, MeshRenderable, linkMeshRenderableShader } from './renderable/mesh';
import { PointsValues, PointsRenderable } from './renderable/points';
import { LinesValues, LinesRenderable } from './renderable/lines';
import { SpheresValues, SpheresRenderable } from './renderable/spheres';
import { SpheresValues, SpheresRenderable, linkSpheresRenderableShader } from './renderable/spheres';
import { TextValues, TextRenderable } from './renderable/text';
import { TextureMeshValues, TextureMeshRenderable } from './renderable/texture-mesh';
import { ImageValues, ImageRenderable } from './renderable/image';
import { CylindersRenderable, CylindersValues } from './renderable/cylinders';
import { CylindersRenderable, CylindersValues, linkCylindersRenderableShader } from './renderable/cylinders';
import { Transparency } from './webgl/render-item';
const getNextId = idFactory(0, 0x7FFFFFFF);
@@ -63,3 +63,13 @@ export function createRenderable<T extends RenderObjectType>(ctx: WebGLContext,
}
throw new Error('unsupported type');
}
export function linkRenderableShader<T extends RenderObjectType>(ctx: WebGLContext, o: GraphicsRenderObject<T>, transparency: Transparency) {
switch (o.type) {
case 'mesh': return linkMeshRenderableShader(ctx, o.id, o.values as MeshValues, o.state, o.materialId, transparency);
case 'spheres': return linkSpheresRenderableShader(ctx, o.id, o.values as SpheresValues, o.state, o.materialId, transparency);
case 'cylinders': return linkCylindersRenderableShader(ctx, o.id, o.values as CylindersValues, o.state, o.materialId, transparency);
}
console.log(o.type);
// throw new Error('unsupported type');
}

View File

@@ -6,7 +6,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { createGraphicsRenderItem, GraphicsRenderVariants, linkRenderItemProgram, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, Values, InternalSchema, SizeSchema, InternalValues, ElementsSpec, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec } from './schema';
import { CylindersShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -45,4 +45,13 @@ export function CylindersRenderable(ctx: WebGLContext, id: number, values: Cylin
const shaderCode = CylindersShaderCode;
const renderItem = createGraphicsRenderItem(ctx, 'triangles', shaderCode, schema, { ...values, ...internalValues }, materialId, transparency);
return createRenderable(renderItem, values, state);
}
export function linkCylindersRenderableShader(ctx: WebGLContext, id: number, values: CylindersValues, state: RenderableState, materialId: number, transparency: Transparency) {
const schema = { ...GlobalUniformSchema, ...GlobalTextureSchema, ...InternalSchema, ...CylindersSchema };
const internalValues: InternalValues = {
uObjectId: ValueCell.create(id),
};
const shaderCode = CylindersShaderCode;
linkRenderItemProgram(ctx, shaderCode, schema, { ...values, ...internalValues }, GraphicsRenderVariants, transparency);
}

View File

@@ -6,7 +6,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { createGraphicsRenderItem, GraphicsRenderVariants, linkRenderItemProgram, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, ElementsSpec, DefineSpec, Values, InternalSchema, InternalValues, GlobalTextureSchema, ValueSpec, UniformSpec } from './schema';
import { MeshShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -41,4 +41,13 @@ export function MeshRenderable(ctx: WebGLContext, id: number, values: MeshValues
const renderItem = createGraphicsRenderItem(ctx, 'triangles', shaderCode, schema, { ...values, ...internalValues }, materialId, transparency);
return createRenderable(renderItem, values, state);
}
export function linkMeshRenderableShader(ctx: WebGLContext, id: number, values: MeshValues, state: RenderableState, materialId: number, transparency: Transparency) {
const schema = { ...GlobalUniformSchema, ...GlobalTextureSchema, ...InternalSchema, ...MeshSchema };
const internalValues: InternalValues = {
uObjectId: ValueCell.create(id),
};
const shaderCode = MeshShaderCode;
linkRenderItemProgram(ctx, shaderCode, schema, { ...values, ...internalValues }, GraphicsRenderVariants, transparency);
}

View File

@@ -6,7 +6,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { createGraphicsRenderItem, GraphicsRenderVariants, linkRenderItemProgram, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, Values, InternalSchema, SizeSchema, InternalValues, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec, TextureSpec } from './schema';
import { SpheresShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -46,4 +46,13 @@ export function SpheresRenderable(ctx: WebGLContext, id: number, values: Spheres
const shaderCode = SpheresShaderCode;
const renderItem = createGraphicsRenderItem(ctx, 'triangles', shaderCode, schema, { ...values, ...internalValues }, materialId, transparency);
return createRenderable(renderItem, values, state);
}
export function linkSpheresRenderableShader(ctx: WebGLContext, id: number, values: SpheresValues, state: RenderableState, materialId: number, transparency: Transparency) {
const schema = { ...GlobalUniformSchema, ...GlobalTextureSchema, ...InternalSchema, ...SpheresSchema };
const internalValues: InternalValues = {
uObjectId: ValueCell.create(id),
};
const shaderCode = SpheresShaderCode;
linkRenderItemProgram(ctx, shaderCode, schema, { ...values, ...internalValues }, GraphicsRenderVariants, transparency);
}

View File

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

View File

@@ -6,7 +6,7 @@
*/
import { WebGLContext } from './webgl/context';
import { GraphicsRenderObject, createRenderable } from './render-object';
import { GraphicsRenderObject, createRenderable, linkRenderableShader as linkRenderableProgram } from './render-object';
import { Object3D } from './object3d';
import { Sphere3D } from '../mol-math/geometry/primitives/sphere3d';
import { CommitQueue } from './commit-queue';
@@ -130,7 +130,9 @@ namespace Scene {
function add(o: GraphicsRenderObject) {
if (!renderableMap.has(o)) {
console.time(`create renderable ${o.type}`);
const renderable = createRenderable(ctx, o, transparency);
console.timeEnd(`create renderable ${o.type}`);
renderables.push(renderable);
if (o.type === 'direct-volume') {
volumes.push(renderable);
@@ -164,6 +166,10 @@ namespace Scene {
let i = 0;
// TODO: have to destroy programs that were not added (=removed before added)
commitQueue.forEachAdd(o => linkRenderableProgram(ctx, o, transparency));
// there could also be a separate queue just for program linking that calls program.finalize()
while (true) {
const o = commitQueue.tryGetRemove();
if (!o) break;

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ export interface Program {
readonly id: number
use: () => void
finalize: () => void
setUniforms: (uniformValues: UniformsList) => void
uniform: (k: string, v: UniformType) => void
bindAttributes: (attribueBuffers: AttributeBuffers) => void
@@ -175,29 +176,38 @@ export function createProgram(gl: GLRenderingContext, state: WebGLState, extensi
let locations: Locations;
let uniformSetters: UniformSetters;
function init() {
function link() {
vertShader.attach(program);
fragShader.attach(program);
console.log('linking', programId)
gl.linkProgram(program);
if (isDebugMode) {
checkProgram(gl, program);
}
locations = getLocations(gl, program, schema);
uniformSetters = getUniformSetters(schema);
// if (isDebugMode) {
// checkProgram(gl, program);
// }
if (isDebugMode) {
checkActiveAttributes(gl, program, schema);
checkActiveUniforms(gl, program, schema);
}
// locations = getLocations(gl, program, schema);
// uniformSetters = getUniformSetters(schema);
// if (isDebugMode) {
// checkActiveAttributes(gl, program, schema);
// checkActiveUniforms(gl, program, schema);
// }
}
init();
link();
let destroyed = false;
return {
id: programId,
finalize: () => {
if (locations) return;
console.log('finalizing', programId)
locations = getLocations(gl, program, schema);
uniformSetters = getUniformSetters(schema);
},
use: () => {
// console.log('use', programId)
state.currentProgramId = programId;
@@ -245,7 +255,7 @@ export function createProgram(gl: GLRenderingContext, state: WebGLState, extensi
reset: () => {
program = getProgram(gl);
init();
link();
},
destroy: () => {
if (destroyed) return;

View File

@@ -128,6 +128,26 @@ export function createComputeRenderItem(ctx: WebGLContext, drawMode: DrawMode, s
return createRenderItem(ctx, drawMode, shaderCode, schema, values, materialId, ComputeRenderVariants, undefined);
}
export function linkRenderItemProgram<T extends string>(ctx: WebGLContext, shaderCode: ShaderCode, schema: RenderableSchema, values: RenderableValues, renderVariants: T[], transparency: Transparency) {
// filter out unsupported variants
renderVariants = renderVariants.filter(v => {
if (v === 'tracing') return !!ctx.extensions.drawBuffers;
return true;
});
// TODO: optimize
const { defineValues } = splitValues(schema, values);
for (const rv of renderVariants) {
const defs = { ...defineValues, dRenderVariant: ValueCell.create(getRenderVariant(rv, transparency)) };
if (schema.dRenderVariant === undefined) {
Object.defineProperty(schema, 'dRenderVariant', { value: DefineSpec('string') });
}
ctx.resources.linkProgram(defs, shaderCode, schema);
}
}
/**
* Creates a render item
*

View File

@@ -56,6 +56,7 @@ export interface WebGLResources {
elements: (array: ElementsType, usageHint?: UsageHint) => ElementsBuffer
pixelPack: (format: TextureFormat, type: TextureType) => PixelPackBuffer
framebuffer: () => Framebuffer
linkProgram: (defineValues: DefineValues, shaderCode: ShaderCode, schema: RenderableSchema) => Program
program: (defineValues: DefineValues, shaderCode: ShaderCode, schema: RenderableSchema) => Program
renderbuffer: (format: RenderbufferFormat, attachment: RenderbufferAttachment, width: number, height: number) => Renderbuffer
shader: (type: ShaderType, source: string) => Shader
@@ -137,9 +138,15 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
framebuffer: () => {
return wrap('framebuffer', createFramebuffer(gl));
},
program: (defineValues: DefineValues, shaderCode: ShaderCode, schema: RenderableSchema) => {
linkProgram: (defineValues: DefineValues, shaderCode: ShaderCode, schema: RenderableSchema) => {
return wrapCached(programCache.get({ defineValues, shaderCode, schema }));
},
program: (defineValues: DefineValues, shaderCode: ShaderCode, schema: RenderableSchema) => {
// TODO: create function
const linked = wrapCached(programCache.get({ defineValues, shaderCode, schema }));
linked.finalize();
return linked;
},
renderbuffer: (format: RenderbufferFormat, attachment: RenderbufferAttachment, width: number, height: number) => {
return wrap('renderbuffer', createRenderbuffer(gl, format, attachment, width, height));
},

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

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

View File

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

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

View File

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

View File

@@ -1,39 +1,48 @@
/**
* 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
});
if (entry.canvas3d?.props?.trackball) {
plugin.canvas3d?.setProps({
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 +51,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 +75,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 +85,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 +97,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,8 +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';
@@ -43,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 }) => {
@@ -82,9 +97,141 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
}
}
},
{
name: 'query',
execute: ({ event, args, manager }) => {
const expression = args['query'];
if (!expression?.length) return;
// supported languages: mol-script, pymol, vmd, jmol
const language = args['lang'] || 'mol-script';
// supported actions: highlight, focus
const action = parseArray(args['action'] || 'highlight');
const focusRadius = parseFloat(args['focus-radius'] || '3');
if (event === 'mouse-leave') {
if (action.includes('highlight')) {
manager.plugin.managers.interactivity.lociHighlights.clearHighlights();
}
return;
}
let query: QueryFn<StructureSelection>;
try {
query = Script.toQuery({
language: language as Script.Language,
expression
});
} catch (e) {
console.warn(`Failed to parse query '${expression}' (${language})`, e);
return;
}
const structures = manager.plugin.state.data.selectQ(q => q.rootsOfType(PluginStateObject.Molecule.Structure));
if (event === 'mouse-enter') {
if (!action.includes('focus')) {
return;
}
manager.plugin.managers.interactivity.lociHighlights.clearHighlights();
for (const structure of structures) {
if (!structure.obj?.data) continue;
const selection = query(new QueryContext(structure.obj.data));
const loci = StructureSelection.toLociWithSourceUnits(selection);
manager.plugin.managers.interactivity.lociHighlights.highlight({
loci,
}, false);
}
}
if (event === 'click') {
if (!action.includes('focus')) {
return;
}
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;
const loci = StructureSelection.toLociWithSourceUnits(selection);
return StructureElement.Loci.getBoundary(loci).sphere;
}).filter(s => !!s);
if (spheres.length) {
manager.plugin.managers.camera.focusSpheres(spheres, s => s, { extraRadius: focusRadius });
}
}
},
},
{
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
@@ -218,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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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