Compare commits

...

103 Commits

Author SHA1 Message Date
dsehnal
fdc33e44dc 5.0.0-dev.10 2025-08-26 17:43:06 +02:00
David Sehnal
b0aa889a0a MVS: Animation improvements (#1631)
* allow interpolation "keyframing"

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

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

* bugfixes & tweaks

* linting

* support "discrete" scalar transform

* tweak audio path

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

* add some TODO comments

* separate audio as mp3. Do coloring with DG scheme

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

* salt bridge.

* better coloring

* support for entry-id test in MolScriptBuilder.

* lint

* update audio, sync animation

* cleanup

* clean up and sync audio/anim

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

* trigger markdown commands from MVS primitives

* docs

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

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

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

* refactoring

* support lammpstrj

* tweaks

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

* Add createMVSX

* mvs: support trackball animation

* tweak

* more fine grained speed of camera spin animation

* update TrackballControlsParams.animate.spin.speed

* story-session-url arg support
2025-08-15 13:27:34 +02:00
giagitom
88cc720dd2 fix render without postprocessing 2025-08-14 23:34:45 +02:00
giagitom
201433cc91 Lint fix 2025-08-14 19:53:36 +02:00
giagitom
8582303491 Add Monolayer transparency (exploiting dpoit) 2025-08-14 19:40:24 +02:00
dsehnal
655c3edadd 5.0.0-dev.6 2025-08-14 11:37:40 +02:00
David Sehnal
a4323a4bd8 MVS animation improvements (#1608) 2025-08-14 11:33:50 +02:00
dsehnal
1b5a7d9546 5.0.0-dev.5 2025-08-13 18:44:02 +02:00
David Sehnal
f165cc4629 MVS: Animations (#1606)
* object hash

* hashed StateTransform.version

* StateTree.reuseTransformParams

* State animation data model

* mvs animation tree schema and builder

* generate animation

* snapshot animation ui

* async animation generation

* ui tweak

* ui tweak

* wrap loadMVS in task

* state snapshots animation

* snapshot transition animation

* autoplay transition

* vector and rotation matrix interpolation

* local rotation transform

* fixes and better demo

* unused import

* tweak

* changelog

* headers

* type => kind

* mat4 interpolation

* use proper time in animation loop

* animated label opacity

* typo

* add postprocessing to demo

* fix mvs postprocessing params

* add transform_matrix interpolation

* tweak

* generalize vector interpolation

* Color.interpolateHcl

* resetCanvasProps

* rename from/to to start/end

* transform def

* add frequency param

* update interpolations

* cache rotation, do not apply noise to last frame

* local_rotation => rotation_center

* changelog

* fix build

* add animation.duration_ms

* PR feedback

* add hsl color space

* default canvas bg

* scalar list interpolation

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

* update metadata for request pullrequest

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

* Write changelog about parsing resname 4-letter

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

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

---------

Co-authored-by: David Sehnal <dsehnal@users.noreply.github.com>
2025-08-06 07:36:20 +02:00
dsehnal
33dc2015df 5.0.0-dev.4 2025-08-05 09:55:55 +02:00
dsehnal
fcf5ea420b npm audit 2025-08-05 09:54:34 +02:00
dsehnal
8d97327f8d MVS: fix passing custom primitive params 2025-08-05 09:53:58 +02:00
midlik
abc7ebba3e MVS: Fix MVSInlinePrimitiveData param type (#1592) 2025-08-03 17:48:19 +02:00
David Sehnal
73d593907e MVS cavnas molstar_postprocessing (#1598) 2025-08-03 12:04:53 +02:00
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
dsehnal
f879519700 5.0.0-dev.2 2025-07-31 18:31:41 +02:00
zachcp
c6e175e5da Update field-schema.ts (#1593)
Follow on from #1587  to make `JS` explicit.
2025-07-31 18:29:28 +02:00
dsehnal
add75bf9c9 5.0.0-dev.1 2025-07-28 16:37:17 +02:00
dsehnal
57cbcd5fbf npm audit 2025-07-28 16:34:08 +02:00
giagitom
50a820b0ae - Add perturbatePositions property
- Fixes and improvements
2025-07-28 16:10:52 +02:00
zachcp
0a33936e06 Update field-schema.ts to point directly to PathReporter (#1587) 2025-07-28 07:48:00 +02:00
Alexander Rose
7291025e09 Merge pull request #1585 from molstar/scene-scale
Scene scale
2025-07-27 17:18:57 -07:00
giagitom
0cb2c3621b Dot volume representation improvements 2025-07-28 02:07:54 +02:00
Alexander Rose
86da258280 Merge branch 'master' of https://github.com/molstar/molstar into scene-scale 2025-07-26 17:42:54 -07:00
Alexander Rose
477a80d1ca fix post-processing params hideIf logic 2025-07-26 17:40:14 -07:00
Alexander Rose
86b68018a9 add scene scaling support 2025-07-26 17:39:44 -07:00
Alexander Rose
da095d6ef9 handling move/dragt before resolving pickData breaks ray-picking 2025-07-26 16:46:08 -07:00
Alexander Rose
dc304b9e08 Merge pull request #1583 from molstar/fix-async-buffer
fix async buffer issues
2025-07-26 16:22:43 -07:00
Alexander Rose
c905fa17c4 tweak 2025-07-26 16:22:33 -07:00
Alexander Rose
a06c64e8e0 Merge pull request #1584 from molstar/pp-switch
add `enable` param for post-processing effects
2025-07-26 16:05:25 -07:00
Alexander Rose
f5441290dd Merge pull request #1567 from giagitom/box3d-spec
add tests for box3D nearestIntersectionWithRay3D
2025-07-26 16:04:59 -07:00
Alexander Rose
9f23124317 move ray box intersection code to Ray3D 2025-07-26 15:59:40 -07:00
dsehnal
8299cd638c tweaks 2025-07-26 20:37:42 +02:00
Alexander Rose
50cb08e74d Merge branch 'master' of https://github.com/molstar/molstar into fix-async-buffer 2025-07-26 07:55:21 -07:00
Alexander Rose
89552652ba Merge branch 'master' of https://github.com/molstar/molstar into pp-switch 2025-07-26 07:55:02 -07:00
Alexander Rose
37ce577813 fix text shader 2025-07-26 07:54:28 -07:00
Alexander Rose
4d9a003141 add enable param for post-processing effects
- If false, no effects are applied.
2025-07-26 07:45:59 -07:00
Alexander Rose
6f0311a53f fix async buffer issues
- mark pick-helper dirty when async pick failed
- add pixel-pack buffer wrapper
- recover pixel-pack buffer after context loss (pick buffer, hi-z pass)
2025-07-26 07:30:54 -07:00
Alexander Rose
bfd2d6b055 text shader: head rotation tweak 2025-07-26 07:23:49 -07:00
Alexander Rose
3072e60709 Merge pull request #1582 from molstar/revert-1581-fix-async-identify
Revert "fix async identify"
2025-07-26 07:22:33 -07:00
Alexander Rose
62ed8d10e3 Revert "fix async identify (#1581)"
This reverts commit 13d3c34864.
2025-07-26 07:22:12 -07:00
David Sehnal
13d3c34864 fix async identify (#1581) 2025-07-25 18:42:29 +02:00
David Sehnal
cac433efca MVS Stories: Add "Download MVS State" link (#1580) 2025-07-25 14:31:07 +02:00
dsehnal
b25ffe7151 Canvas3dInteractionHelper fix 2025-07-25 10:53:02 +02:00
David Sehnal
31074dc74c fix inv het rotation uniform (#1578) 2025-07-25 10:34:13 +02:00
giagitom
c98c01a076 fix names 2025-07-23 18:24:56 +02:00
giagitom
8966fc9396 refactor 2025-07-23 18:23:12 +02:00
dsehnal
fdbdc551e8 fix web component syntax 2025-07-22 15:25:44 +02:00
dsehnal
bb232ac3a4 pass format in mvs stories app 2025-07-22 14:37:51 +02:00
giagitom
735c25ef8d Merge branch 'master' of https://github.com/molstar/molstar into box3d-spec 2025-07-22 14:13:07 +02:00
Alexander Rose
298043313a head rotation handling tweaks 2025-07-20 22:07:37 -07:00
Alexander Rose
77cd181b91 add addCylinderFromRay3D helper function 2025-07-20 09:02:31 -07:00
Alexander Rose
b5bee042e8 add groupCount argument to Shape.create 2025-07-20 08:48:43 -07:00
Alexander Rose
4faf17ddc7 Merge pull request #1576 from molstar/headrotation
Add head rotation support
2025-07-20 08:45:15 -07:00
Alexander Rose
28774b2277 fix scale issues in cylinders & spheres shaders 2025-07-19 16:13:48 -07:00
Alexander Rose
6a7444f44e add head rotation support
- handle skybox
- handle sphere & text billboards
2025-07-19 16:13:00 -07:00
Alexander Rose
15bfa8416a Merge pull request #1575 from molstar/async-ray-picking
add async & ray picking
2025-07-19 15:16:01 -07:00
Alexander Rose
e6895ec833 cleanup, simplify AsyncPickData 2025-07-19 15:08:46 -07:00
Alexander Rose
2099ad728a add async & ray picking 2025-07-19 09:13:49 -07:00
giagitom
6fc04c3294 add tests for box3D nearestIntersectionWithRay3D 2025-07-07 18:46:06 +02:00
151 changed files with 6274 additions and 3714 deletions

View File

@@ -5,12 +5,15 @@ Note that since we don't clearly distinguish between a public and private interf
## [Unreleased]
- [Breaking] Renamed some color schemes ('inferno' -> 'inferno-no-black', 'magma' -> 'magma-no-black', 'turbo' -> 'turbo-no-black', 'rainbow' -> 'simple-rainbow')
- [Breaking] `Box3D.nearestIntersectionWithRay` -> `nearestIntersectionWithRay3D` (use `Ray3D`)
- [Breaking] `Box3D.nearestIntersectionWithRay` -> `Ray3D.intersectBox3D`
- [Breaking] `Plane3D.distanceToSpher3D` -> `distanceToSphere3D` (fix spelling)
- [Breaking] fix typo `MarchinCubes` -> `MarchingCubes`
- [Breaking] `PluginContext.initViewer/initContainer/mount` are now async and have been renamed to include `Async` postfix
- [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
@@ -18,24 +21,39 @@ Note that since we don't clearly distinguish between a public and private interf
- MolViewSpec extension:
- Generic color schemes (`palette` parameter for color_from_* nodes)
- Annotation field remapping (`field_remapping` parameter for color_from_* nodes)
- Representation node: support custom property `molstar_reprepresentation_params`,
- 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), and fog
- `clip` node support for structure and volume representations
- `grid_slice` representation support for volumes
- Support tethers and background for primitive labels
- Support `snapshot_key` parameter on primitives that enables transition between states via clicking on 3D objects
- Inline selectors and MVS annotations support `instance_id`
- Support `matrix` on transform params
- Support `surface_type` (`molecular` / `gaussian`) on for `surface` representation nodes
- Add `instance` node type
- Add `transform.rotation_center` property that enables rotating an object around its centroid or a specific point
- Support transforming and instancing of structures, components, and volumes
- Use params hash for node version for more performant tree diffs
- Add `Snapshot.animation` support that enables animating almost every property in a given tree
- Add `createMVSX` helper function
- Support Mol* trackball animation via `animation.custom.molstar_trackball`
- MVSX - use Murmur hash instead of FNV in archive URI
- Support additional file formats (pdbqt, gro, xyz, mol, sdf, mol2, xtc, lammpstrj)
- Support loading trajectory coordinates from separate nodes
- Trigger markdown commands from primitives using `molstar_markdown_commands` custom extensions
- Support `molstar_on_load_markdown_commands` custom state on the `root` node
- Added new color schemes, synchronized with D3.js ('inferno', 'magma', 'turbo', 'rainbow', 'sinebow', 'warm', 'cool', 'cubehelix-default', 'category-10', 'observable-10', 'tableau-10')
- Snapshot Markdown improvements
- Add `MarkdownExtensionManager` (`PluginContext.managers.markdownExtensions`)
- Support custom markdown commands to control the plugin via the `[link](!command)` pattern
- Support rendering custom elements via the `![alt](!parameters)` pattern
- Support tables
- Support loading images from MVSX files
- Support loading images and audio from MVSX files
- Indicate external links with ⤴
- Audio support
- Add `PluginState.Snapshot.onLoadMarkdownCommands`
- Avoid calculating rings for coarse-grained structures
- Fix isosurface compute shader normals when transformation matrix is applied to volume
- Symmetry operator naming for spacegroup symmetry - parenthesize multi-character indices (1_111-1 -> 1_(11)1(-1))
@@ -49,7 +67,39 @@ Note that since we don't clearly distinguish between a public and private interf
- Add `Ray3D` object and helpers
- Volume slice representation: add `relativeX/Y/Z` options for dimension
- Add `StructureInstances` transform
- Add `story-id` URL arg support to `mvs-stories` app
- `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
- Can be enabled with new `Canvas3dInteractionHelperParams.convertCoordsToRay` param
- Allows to have input methods that are 3D pointers in the scene
- Add `ray: Ray3D` property to `DragInput`, `ClickInput`, and `MoveInput`
- Add async, non-blocking picking (only WebGL2)
- Refactor `Canvas3dInteractionHelper` internals to use async picking for move events
- Add `enable` param for post-processing effects. If false, no effects are applied.
- Dot volume representation improvements
- Add positional perturbation to avoid camera artifacts
- Fix handling of negative isoValues by considering only volume cells with values lower than isoValue (#1559)
- Fix volume-value size theme
- Change the parsing of residue names in PDB files from 3-letter to 4-letter.
- Support versioning transform using a hash function in `mol-state`
- Support for "state snapshot transitions"
- Add `PluginState.Snapshot.transition` that enables associating a state snapshot with a list states that can be animated
- Add `AnimateStateSnapshotTransition` animation
- Update the snapshots UI to support this feature
- Use "proper time" in the animation loop to prevent animation skips during blocking operations (e.g., shader complication)
- Add `Hsl` and (normalized) `Rgb` color spaces
- Add `Color.interpolateHsl`
- Add `rotationCenter` property to `TransformParam`
- Add Monolayer transparency (exploiting dpoit).
- Add plugin config item ShowReset (shows/hides "Reset Zoom" button)
- Fix transform params not being normalized when used together with param hash version
- Replace `immer` with `mutative`
## [v4.18.0] - 2025-06-08
- MolViewSpec extension:

View File

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

2838
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ export class MVSStoriesContext {
commands = new BehaviorSubject<MVSStoriesCommand | undefined>(undefined);
state = {
viewers: new BehaviorSubject<{ name?: string, model: MVSStoriesViewerModel }[]>([]),
currentStoryData: new BehaviorSubject<string | Uint8Array | undefined>(undefined),
isLoading: new BehaviorSubject(false),
};
@@ -27,7 +28,7 @@ export class MVSStoriesContext {
}
}
export function getMVSStoriesContext(options?: { name?: string, container?: object }) {
export function getMVSStoriesContext(options?: { name?: string, container?: object }): MVSStoriesContext {
const container: any = options?.container ?? window;
container.componentContexts ??= {};
const name = options?.name ?? '<default>';

View File

@@ -6,6 +6,8 @@
import { MolViewSpec } from '../../../extensions/mvs/behavior';
import { loadMVSData } from '../../../extensions/mvs/components/formats';
import { MVSData } from '../../../extensions/mvs/mvs-data';
import { StringLike } from '../../../mol-io/common/string-like';
import { PluginComponent } from '../../../mol-plugin-state/component';
import { createPluginUI } from '../../../mol-plugin-ui';
import { renderReact18 } from '../../../mol-plugin-ui/react18';
@@ -56,11 +58,17 @@ export class MVSStoriesViewerModel extends PluginComponent {
try {
this.context.state.isLoading.next(true);
if (cmd.kind === 'load-mvs') {
let loadedData: MVSData | StringLike | Uint8Array | undefined;
if (cmd.url) {
const data = await this.plugin.runTask(this.plugin.fetch({ url: cmd.url, type: cmd.format === 'mvsx' ? 'binary' : 'string' }));
await loadMVSData(this.plugin, data, cmd.format ?? 'mvsj', { sourceUrl: cmd.url });
loadedData = await loadMVSData(this.plugin, data, cmd.format ?? 'mvsj', { sourceUrl: cmd.url });
} else if (cmd.data) {
await loadMVSData(this.plugin, cmd.data, cmd.format ?? 'mvsj');
loadedData = await loadMVSData(this.plugin, cmd.data, cmd.format ?? 'mvsj');
}
if (StringLike.is(loadedData) || loadedData instanceof Uint8Array) {
this.context.state.currentStoryData.next(loadedData as string | Uint8Array);
} else if (loadedData) {
this.context.state.currentStoryData.next(JSON.stringify(loadedData));
}
}
} catch (e) {

View File

@@ -38,6 +38,25 @@
gap: 16px;
}
#links {
position: absolute;
bottom: 4px;
right: 8px;
font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 0.6rem;
z-index: -1;
color: #666;
}
#links a {
color: #666;
text-decoration: none;
}
#links .sep {
color: #aaa;
}
@media (orientation:portrait) {
#viewer {
position: absolute;
@@ -68,16 +87,23 @@
<body>
<!-- the context-name parameter is optional and useful when embedding multiple stories in a single page -->
<div id="viewer">
<mvs-stories-viewer context-name="story1" />
<mvs-stories-viewer context-name="story1" ></mvs-stories-viewer>
</div>
<div id="controls">
<mvs-stories-snapshot-markdown context-name="story1" style="flex-grow: 1;" />
<mvs-stories-snapshot-markdown context-name="story1" style="flex-grow: 1;" ></mvs-stories-snapshot-markdown>
</div>
<div id="links">
<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:
@@ -85,11 +111,36 @@
// storyUrl = 'https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/kinase-story.mvsj';
// }
var molstarDataLink = storyUrl;
var editInStoriesUrl = undefined;
if (storyId) {
mvsStories.loadFromID(storyId, { contextName: 'story1' });
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' });
});
</script>
<!-- __MOLSTAR_ANALYTICS__ -->
</body>

View File

@@ -7,6 +7,7 @@
import { getMVSStoriesContext } from './context';
import './elements';
import { MVSData } from '../../extensions/mvs/mvs-data';
import { download } from '../../mol-util/download';
import './favicon.ico';
import '../../mol-plugin-ui/skin/light.scss';
@@ -48,4 +49,16 @@ export function loadFromID(id: string, options?: { format?: 'mvsx' | 'mvsj', con
);
}
export function downloadCurrentStory(options?: { contextName?: string, filename?: string }) {
const story = getContext(options?.contextName).state.currentStoryData.value;
if (!story) return;
const isMVSJ = typeof story === 'string';
const filename = `${options?.filename ?? 'story'}.${isMVSJ ? 'mvsj' : 'mvsx'}`;
download(
new Blob([typeof story === 'string' ? story : story.buffer], { type: isMVSJ ? 'application/json' : 'application/octet-stream' }),
filename
);
};
export { MVSData };

View File

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

View File

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

View File

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

View File

@@ -98,14 +98,14 @@
<body>
<div id="viewer">
<mvs-stories-viewer />
<mvs-stories-viewer></mvs-stories-viewer>
</div>
<div id="controls">
<div id="select-story" class="select-story"></div>
<mvs-stories-snapshot-markdown style="flex-grow: 1;" />
<mvs-stories-snapshot-markdown style="flex-grow: 1;"></mvs-stories-snapshot-markdown>
</div>
<div id="links">
<a href="#" id="mvs-data" filename="kinase-story.mvsj">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/examples/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
<a href="#" id="mvs-data">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/examples/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
</div>
<script>

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -6,7 +6,10 @@
*/
import { Camera } from '../../mol-canvas3d/camera';
import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
import { CameraFogParams, Canvas3DParams, Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
import { TrackballControlsParams } from '../../mol-canvas3d/controls/trackball';
import { BloomParams } from '../../mol-canvas3d/passes/bloom';
import { DofParams } from '../../mol-canvas3d/passes/dof';
import { OutlineParams } from '../../mol-canvas3d/passes/outline';
import { ShadowParams } from '../../mol-canvas3d/passes/shadow';
import { SsaoParams } from '../../mol-canvas3d/passes/ssao';
@@ -22,6 +25,7 @@ import { ParamDefinition } from '../../mol-util/param-definition';
import { decodeColor } from './helpers/utils';
import { MolstarLoadingContext } from './load';
import { SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { MolstarNode, MolstarNodeParams } from './tree/molstar/molstar-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
@@ -119,37 +123,93 @@ export function createPluginStateSnapshotCamera(plugin: PluginContext, context:
return camera;
}
/** Set canvas properties based on a canvas node. */
export function setCanvas(plugin: PluginContext, node: MolstarNode<'canvas'> | undefined) {
plugin.canvas3d?.setProps(old => modifyCanvasProps(old, node));
function optionalParams(enable: boolean | undefined, values: any, params: ParamDefinition.Params, fallback: any) {
if (typeof enable === 'boolean') {
return enable
? { name: 'on', params: { ...ParamDefinition.getDefaultValues(params), ...values } }
: { name: 'off', params: {} };
}
return fallback;
}
/** Create a deep copy of `oldCanvasProps` with values modified according to a canvas node params. */
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, custom?: Record<string, any>): Canvas3DProps {
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, animationNode: MVSAnimationNode<'animation'> | undefined): Canvas3DProps {
const params = canvasNode?.params;
const backgroundColor = decodeColor(params?.background_color) ?? DefaultCanvasBackgroundColor;
const outline = !!canvasNode?.custom?.molstar_enable_outline;
const 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 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),
},
cameraFog: optionalParams(fog, fogParams, CameraFogParams, oldCanvasProps.cameraFog),
renderer: {
...oldCanvasProps.renderer,
backgroundColor: backgroundColor,
},
trackball: {
...oldCanvasProps?.trackball,
...(trackballAnimationName
? {
animate: {
name: trackballAnimationName,
params: {
...TrackballControlsParams.animate.map(trackballAnimationName)?.defaultValue,
...trackballAnimationParams
}
}
}
: {}
),
}
};
}
export function resetCanvasProps(plugin: PluginContext) {
const old = plugin.canvas3d?.props;
plugin.canvas3d?.setProps({
...old,
postprocessing: {
...old,
outline: DefaultCanvas3DParams.postprocessing.outline,
shadow: DefaultCanvas3DParams.postprocessing.shadow,
occlusion: DefaultCanvas3DParams.postprocessing.occlusion,
dof: DefaultCanvas3DParams.postprocessing.dof,
bloom: DefaultCanvas3DParams.postprocessing.bloom,
},
cameraFog: DefaultCanvas3DParams.cameraFog,
trackball: {
...old?.trackball,
animate: { name: 'off', params: {} },
}
});
}

View File

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

View File

@@ -5,7 +5,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { hashFnv32a } from '../../../mol-data/util';
import { murmurHash3_128_fromBytes } from '../../../mol-data/util';
import { StringLike } from '../../../mol-io/common/string-like';
import { DataFormatProvider } from '../../../mol-plugin-state/formats/provider';
import { PluginStateObject as SO } from '../../../mol-plugin-state/objects';
@@ -118,7 +118,7 @@ export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext
// states.
clearMVSXFileAssets(plugin);
const archiveId = `ni,fnv1a;${hashFnv32a(data)}`;
const archiveId = `ni,MurmurHash3_128;${murmurHash3_128_fromBytes(data, 42)}`;
let files: { [path: string]: Uint8Array };
try {
files = await unzip(runtimeCtx, data) as typeof files;
@@ -166,6 +166,8 @@ export async function loadMVSData(plugin: PluginContext, data: MVSData | StringL
} else {
throw new Error(`Unknown MolViewSpec format: ${format}`);
}
return data;
}
function clearMVSXFileAssets(plugin: PluginContext) {
@@ -180,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,605 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Ludovic Autin <ludovic.autin@gmail.com>
*/
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { EPSILON, Mat3, Mat4, Quat, Vec3 } from '../../../mol-math/linear-algebra';
import { RuntimeContext } from '../../../mol-task';
import { deepEqual } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { produce } from '../../../mol-util/produce';
import { makeContinuousPaletteCheckpoints, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from '../components/annotation-color-theme';
import { palettePropsFromMVSPalette } from '../load-helpers';
import { Snapshot } from '../mvs-data';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { Tree } from '../tree/generic/tree-schema';
import { addDefaults } from '../tree/generic/tree-utils';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { ColorT } from '../tree/mvs/param-types';
export async function generateStateTransition(ctx: RuntimeContext, snapshot: Snapshot, snapshotIndex: number, snapshotCount: number) {
if (!snapshot.animation) return undefined;
const tree = addDefaults(snapshot.animation, MVSAnimationSchema);
const transitions = tree.children?.filter(child => child.kind === 'interpolate');
if (!transitions?.length) return undefined;
const duration = Math.max(
snapshot.animation.params?.duration_ms ?? 0,
...transitions.map(t => (t.params.start_ms ?? 0) + t.params.duration_ms)
);
const frames: [tree: MVSTree, time: number][] = [];
const dt = tree.params?.frame_time_ms ?? (1000 / 60);
const N = Math.ceil(duration / dt);
const nodeMap = makeNodeMap(snapshot.root, new Map(), []);
const cache = new Map<any, InterpolationCacheEntry>();
const transitionGroups = groupTranstions(transitions);
let prevRoot: MVSTree | undefined;
for (let i = 0; i <= N; i++) {
const t = i * dt;
const root = createSnapshot(snapshot.root, transitionGroups, t, cache, nodeMap);
if (root === prevRoot || (prevRoot && deepEqual(root, prevRoot))) {
frames[frames.length - 1][1] += dt;
} else {
frames.push([root, dt]);
}
prevRoot = root;
if (ctx.shouldUpdate) {
await ctx.update({ message: `Generating transition for snapshot ${snapshotIndex + 1}/${snapshotCount}`, current: i + 1, max: N });
}
}
return { tree, frametimeMs: dt, frames };
}
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = {
'linear': t => t,
'bounce-in': EasingFns.bounceIn,
'bounce-out': EasingFns.bounceOut,
'bounce-in-out': EasingFns.bounceInOut,
'circle-in': EasingFns.circleIn,
'circle-out': EasingFns.circleOut,
'circle-in-out': EasingFns.circleInOut,
'cubic-in': EasingFns.cubicIn,
'cubic-out': EasingFns.cubicOut,
'cubic-in-out': EasingFns.cubicInOut,
'exp-in': EasingFns.expIn,
'exp-out': EasingFns.expOut,
'exp-in-out': EasingFns.expInOut,
'quad-in': EasingFns.quadIn,
'quad-out': EasingFns.quadOut,
'quad-in-out': EasingFns.quadInOut,
'sin-in': EasingFns.sinIn,
'sin-out': EasingFns.sinOut,
'sin-in-out': EasingFns.sinInOut,
};
interface InterpolationCacheEntry {
paletteFn?: (value: number) => Color,
rotation?: { axis: Vec3, angle: number, start: Quat, end: Quat },
}
function getTransitionKey(transition: MVSAnimationNode<'interpolate'>) {
const prop = transition.params.property;
if (Array.isArray(prop)) {
return `${transition.params.target_ref}:${prop.join('.')}`;
}
return `${transition.params.target_ref}:${prop}`;
}
function groupTranstions(transitions: MVSAnimationNode<'interpolate'>[]) {
const map = new Map<string, MVSAnimationNode<'interpolate'>[]>();
const groups: MVSAnimationNode<'interpolate'>[][] = [];
for (const t of transitions) {
const key = getTransitionKey(t);
if (!map.has(key)) {
const group: MVSAnimationNode<'interpolate'>[] = [];
map.set(key, group);
groups.push(group);
}
map.get(key)!.push(t);
}
for (const group of groups) {
group.sort((a, b) => {
const s = (a.params.start_ms ?? 0) - (b.params.start_ms ?? 0);
if (s !== 0) return s;
return a.params.duration_ms - b.params.duration_ms;
});
}
return groups;
}
function createSnapshot(tree: MVSTree, transitionGroups: MVSAnimationNode<'interpolate'>[][], time: number, cache: Map<any, InterpolationCacheEntry>, nodeMap: Map<string, (string | number)[]>) {
let modified = false;
const ret = produce(tree, (draft) => {
for (const transitionGroup of transitionGroups) {
const pivot = transitionGroup[0];
const nodePath = nodeMap.get(pivot.params.target_ref);
if (!nodePath) continue;
const node = select(draft, nodePath, 0);
const target = pivot.params.property[0] === 'custom' ? node?.custom : node?.params;
if (!target) continue;
const offset = pivot.params.property[0] === 'custom' ? 1 : 0;
let transition: MVSAnimationNode<'interpolate'> = pivot;
let previous: MVSAnimationNode<'interpolate'> | undefined;
for (let i = transitionGroup.length - 1; i > 0; i--) {
const current = transitionGroup[i];
const currentStart = current.params.start_ms ?? 0;
if (time >= currentStart) {
transition = current;
previous = i > 0 ? transitionGroup[i - 1] : undefined;
break;
}
}
if (!cache.has(transition)) {
cache.set(transition, {});
}
const cacheEntry: InterpolationCacheEntry = cache.get(transition)!;
const startTime: number = transition.params.start_ms ?? 0;
const durationMs: number = transition.params.duration_ms ?? 0;
const t = (time - startTime) / durationMs;
let next: any;
if (transition.params.kind === 'transform_matrix') {
next = processTransformMatrix(transition, target, clamp(t, 0, 1), cacheEntry, offset, previous);
} else {
next = processScalarLike(transition, target, t, cacheEntry, offset, previous);
}
if (next === undefined) {
continue;
}
modified = true;
assign(target, transition.params.property, next, offset);
}
});
return modified ? ret : tree;
}
function applyFrequency(t: number, frequency: number, alternate: boolean) {
let v = (t * (frequency || 1));
if (v < 1) return v;
if (!alternate) {
v = (v % 1);
if (v === 0) return 1;
return v;
}
if (Math.abs(v - 1) < EPSILON) return 1;
v = v % 2;
if (v > 1) return 2 - v;
return v;
}
function getPreviousScalarEnd(previous: MVSAnimationNode<'interpolate'> | undefined) {
if (!previous || previous.params.kind === 'transform_matrix') return undefined;
return previous.params.end;
}
function processScalarLike(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cacheEntry: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind === 'transform_matrix') return;
if (previous && previous.params.kind === 'transform_matrix') return;
const startBase = transition.params.start ?? getPreviousScalarEnd(previous) ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition, startBase, transition.params.end as ColorT | undefined);
}
const paletteFn = cacheEntry.paletteFn!;
const startValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(0))
: startBase;
const endValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(1))
: transition.params.end;
if (time <= 0) return startValue;
else if (time >= 1 - EPSILON && !transition.params.alternate_direction) return endValue;
let t = clamp(time, 0, 1);
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
if (transition.params.kind === 'scalar') {
return interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
return interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
return interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
const color = paletteFn(t);
return Color.toHexStyle(color);
}
}
function getPreviousMatrixEnd(previous: MVSAnimationNode<'interpolate'> | undefined, prop: 'rotation_start' | 'translation_start' | 'scale_start') {
if (!previous || previous.params.kind !== 'transform_matrix') return undefined;
return previous.params[prop];
}
const TransformState = {
pivotTranslation: Mat4(),
pivotTranslationInv: Mat4(),
rotation: Mat4(),
scale: Mat4(),
translation: Mat4(),
pivotNeg: Vec3(),
temp: Mat4(),
};
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind !== 'transform_matrix') return;
if (previous && previous.params.kind !== 'transform_matrix') return;
const transform = select(target, transition.params.property, offset) ?? Mat4.identity();
const startRotation = transition.params.rotation_start ?? getPreviousMatrixEnd(previous, 'rotation_start') ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? getPreviousMatrixEnd(previous, 'translation_start') ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? getPreviousMatrixEnd(previous, 'scale_start') ?? Mat4.getScaling(Vec3(), transform);
const endRotation = transition.params.rotation_end;
const endTranslation = transition.params.translation_end;
const endScale = transition.params.scale_end;
let rotation, translation, scale;
if (time <= 0) {
rotation = startRotation as Mat3;
translation = startTranslation as Vec3;
scale = startScale as Vec3;
} else {
const clampedTime = clamp(time, 0, 1);
let t = applyFrequency(clampedTime, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
t = applyFrequency(clampedTime, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
t = applyFrequency(clampedTime, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
}
const pivot = transition.params.pivot ?? Vec3.zero();
Mat4.fromTranslation(TransformState.translation, translation);
Mat4.fromScaling(TransformState.scale, scale);
Mat4.setIdentity(TransformState.rotation);
Mat4.fromMat3(TransformState.rotation, rotation);
Mat4.fromTranslation(TransformState.pivotTranslation, pivot as Vec3);
Mat4.fromTranslation(TransformState.pivotTranslationInv, Vec3.negate(TransformState.pivotNeg, pivot as Vec3));
// translation . pivot . rotation . scale . pivotInv
const result = Mat4();
Mat4.mul(result, TransformState.scale, TransformState.pivotTranslationInv);
Mat4.mul(result, TransformState.rotation, result);
Mat4.mul(result, TransformState.translation, result);
return result;
}
function interpolateScalars(start: number | number[], end: number | number[] | undefined, t: number, noise: number, discrete: boolean) {
if (Array.isArray(start)) {
const ret = Array.from<number>({ length: start.length }).fill(0.1);
if (!end || !Array.isArray(end)) {
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end, t, noise, discrete);
}
return ret;
}
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end[i], t, noise, discrete);
}
return ret;
}
if (Array.isArray(end)) {
const ret = Array.from<number>({ length: end.length }).fill(0.1);
for (let i = 0; i < end.length; i++) {
ret[i] = interpolateScalar(start, end[i], t, noise, discrete);
}
return ret;
}
return interpolateScalar(start, end, t, noise, discrete);
}
function interpolateScalar(start: number, end: number | undefined, t: number, noise: number, discrete: boolean) {
let v = typeof end === 'number' ? lerp(start, end, t) : start;
if (noise) {
v += (Math.random() - 0.5) * noise;
}
if (discrete) {
v = Math.round(v);
}
return v;
}
const InterpolateVectorsState = {
start: Vec3(),
end: Vec3(),
v: Vec3(),
};
function interpolateVectors(start: number[], end: number[] | undefined, t: number, noise: number, isSpherical: boolean) {
if ((!end || start === end) && !noise) return start;
const ret: number[] = Array.from<number>({ length: start.length }).fill(0.1);
for (let i = 0; i < start.length; i += 3) {
const s = Vec3.fromArray(InterpolateVectorsState.start, start, i);
let v: Vec3;
if (end) {
const e = Vec3.fromArray(InterpolateVectorsState.end, end, i);
v = isSpherical
? Vec3.slerp(InterpolateVectorsState.v, s, e, t)
: Vec3.lerp(InterpolateVectorsState.v, s, e, t);
} else {
v = Vec3.clone(s);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(Vec3Noise, noise);
Vec3.add(v, v, Vec3Noise);
}
Vec3.toArray(v, ret, i);
}
return ret;
}
const Vec3Noise = Vec3();
function interpolateVec3(start: Vec3, end: Vec3 | undefined, t: number, noise: number, isSpherical: boolean) {
if ((!end || start === end) && !noise) return start;
let v: Vec3;
if (end) {
v = isSpherical
? Vec3.slerp(Vec3(), start, end, t)
: Vec3.lerp(Vec3(), start, end, t);
} else {
v = Vec3.clone(start);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(Vec3Noise, noise);
Vec3.add(v, v, Vec3Noise);
}
return v;
}
const RotationState = {
start: Quat(),
end: Quat(),
v: Quat(),
noise: Quat(),
axis: Vec3(),
temp: Mat4(),
};
function interpolateRotation(start: Mat3, end: Mat3 | undefined, t: number, noise: number, cache: InterpolationCacheEntry) {
if ((!end || start === end) && !noise) return start;
if (end) {
if (!cache.rotation) {
cache.rotation = {
...relativeAxisAngle(start, end),
start: Quat.fromMat3(Quat(), start),
end: Quat.fromMat3(Quat(), end),
};
}
const { axis, angle, start: startQ, end: endQ } = cache.rotation;
if (angle < 1e-6) {
// start ≈ end: make a clean spin about the detected (or default) axis
Quat.setAxisAngle(RotationState.v, axis, t * 2 * Math.PI); // Make a full turn
} else {
// Normal case: stick with your existing slerp between start/end
Quat.slerp(RotationState.v, startQ, endQ, t);
}
} else {
Quat.fromMat3(RotationState.v, start);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(RotationState.axis, 1);
Quat.setAxisAngle(RotationState.noise, RotationState.axis, 2 * Math.PI * noise * (Math.random() - 0.5));
Quat.multiply(RotationState.v, RotationState.noise, RotationState.v);
}
Mat4.fromQuat(RotationState.temp, RotationState.v);
return Mat3.fromMat4(Mat3(), RotationState.temp);
}
function select(params: any, path: string | (string | number)[], offset: number) {
if (typeof path === 'string') {
return params?.[path];
}
let f = params;
for (let i = offset; i < path.length; i++) {
if (!f) break;
f = f[path[i]];
}
return f;
}
function assign(params: any, path: string | (string | number)[], value: any, offset: number) {
if (!params) return;
if (typeof path === 'string') {
params[path] = value;
return;
}
let f = params;
for (let i = offset; i < path.length; i++) {
if (!f) break;
if (i === path.length - 1) {
f[path[i]] = value;
} else {
f = f[path[i]];
}
}
}
function makeNodeMap(tree: Tree, map: Map<string, (string | number)[]>, currentPath: (string | number)[]) {
if (tree.ref) {
map.set(tree.ref, [...currentPath]);
}
if (!tree.children) return map;
currentPath.push('children');
for (let i = 0; i < tree.children.length; i++) {
const child = tree.children[i];
currentPath.push(i);
makeNodeMap(child, map, currentPath);
currentPath.pop();
}
currentPath.pop();
return map;
}
function makePaletteFunction(props: MVSAnimationNode<'interpolate'>, start: ColorT | undefined | null, end: ColorT | undefined | null): ((value: number) => Color) | undefined {
if (props.params.kind !== 'color') return undefined;
const params = props.params.palette
? palettePropsFromMVSPalette(props.params.palette)
: palettePropsFromMVSPalette({ kind: 'continuous', colors: [start ?? 'black', end ?? start ?? 'black'] });
if (params.name === 'discrete') return makePaletteFunctionDiscrete(params.params);
if (params.name === 'continuous') return makePaletteFunctionContinuous(params.params);
throw new Error(`NotImplementedError: makePaletteFunction for ${(props as any).name}`);
}
function makePaletteFunctionDiscrete(props: MVSDiscretePaletteProps): (value: number) => Color {
const defaultColor = Color(0x0);
if (props.colors.length === 0) return () => defaultColor;
return (value: number) => {
const x = clamp(value, 0, 1);
for (let i = props.colors.length - 1; i >= 0; i--) {
const { color, fromValue, toValue } = props.colors[i];
if (fromValue <= x && x <= toValue) return color;
}
return defaultColor;
};
}
function makePaletteFunctionContinuous(props: MVSContinuousPaletteProps): (value: number) => Color {
const defaultColor = Color(0x0);
const { colors, checkpoints } = makeContinuousPaletteCheckpoints(props);
if (colors.length === 0) return () => defaultColor;
const underflowColor = props.setUnderflowColor ? props.underflowColor : defaultColor;
const overflowColor = props.setOverflowColor ? props.overflowColor : defaultColor;
return (value: number) => {
const x = clamp(value, 0, 1);
const gteIdx = SortedArray.findPredecessorIndex(checkpoints, x); // Index of the first greater or equal checkpoint
if (gteIdx === 0) {
if (x === checkpoints[0]) return colors[0];
else return underflowColor;
}
if (gteIdx === checkpoints.length) {
return overflowColor;
}
const q = (x - checkpoints[gteIdx - 1]) / (checkpoints[gteIdx] - checkpoints[gteIdx - 1]);
return Color.interpolate(colors[gteIdx - 1], colors[gteIdx], q);
};
}
const RelativeAxisAngleState = {
Rt: Mat3(),
R: Mat3(),
};
function relativeAxisAngle(start: Mat3, end: Mat3): { axis: Vec3, angle: number } {
// R_rel = end * start^T
const R0 = start, R1 = end;
const Rt = Mat3.transpose(RelativeAxisAngleState.Rt, R0);
const R = Mat3.mul(RelativeAxisAngleState.R, R1, Rt);
const tr = R[0] + R[4] + R[8]; // trace
let angle = Math.acos(clamp((tr - 1) * 0.5, -1, 1)); // in [0, π]
const axis = Vec3();
const eps = 1e-6;
const sinA = Math.sin(angle);
if (angle < eps) {
// Near identity: axis undefined; return any unit axis (choose something stable)
Vec3.set(axis, 0, 0, 1);
angle = 0.0;
return { axis, angle };
}
if (Math.PI - angle > 1e-4) {
// General case
axis[0] = (R[5] - R[7]) / (2 * sinA); // (r32 - r23)
axis[1] = (R[6] - R[2]) / (2 * sinA); // (r13 - r31)
axis[2] = (R[1] - R[3]) / (2 * sinA); // (r21 - r12)
Vec3.normalize(axis, axis);
return { axis, angle };
}
// angle ~ π: use diagonal-based extraction for stability
// Compute squared components then pick the largest to avoid precision loss
const xx = Math.max(0, (R[0] + 1) * 0.5);
const yy = Math.max(0, (R[4] + 1) * 0.5);
const zz = Math.max(0, (R[8] + 1) * 0.5);
let x = Math.sqrt(xx), y = Math.sqrt(yy), z = Math.sqrt(zz);
if (x >= y && x >= z) {
x = Math.max(x, 1e-8);
y = (R[1] + R[3]) / (4 * x);
z = (R[2] + R[6]) / (4 * x);
Vec3.set(axis, x, y, z);
} else if (y >= x && y >= z) {
y = Math.max(y, 1e-8);
x = (R[1] + R[3]) / (4 * y);
z = (R[5] + R[7]) / (4 * y);
Vec3.set(axis, x, y, z);
} else {
z = Math.max(z, 1e-8);
x = (R[2] + R[6]) / (4 * z);
y = (R[5] + R[7]) / (4 * z);
Vec3.set(axis, x, y, z);
}
Vec3.normalize(axis, axis);
return { axis, angle: Math.PI };
}

View File

@@ -100,7 +100,10 @@ export type ElementOfSet<S> = S extends Set<infer T> ? T : never
/** Convert `colorString` (either X11 color name like 'magenta' or hex code like '#ff00ff') to Color.
* Return `undefined` if `colorString` cannot be converted. */
export function decodeColor(colorString: string | undefined | null): Color | undefined {
export function decodeColor(colorString: string | number | undefined | null): Color | undefined {
if (typeof colorString === 'number') {
return Color(colorString);
}
return _decodeColor(colorString);
}
@@ -149,4 +152,25 @@ export function collectMVSReferences<T extends StateObject.Ctor>(type: T[], depe
}
return ret;
}
export function getMVSReferenceObject<T extends StateObject.Ctor>(type: T[], dependencies: Record<string, StateObject> | undefined, ref: string): StateObject | undefined {
if (!dependencies) return undefined;
for (const key of Object.keys(dependencies)) {
const o = dependencies[key];
let okType = false;
for (const t of type) {
if (t.is(o)) {
okType = true;
break;
}
}
if (!okType || !o.tags) continue;
for (const tag of o.tags) {
if (tag.startsWith('mvs-ref:')) {
if (tag.substring(8) === ref) return o;
}
}
}
}

View File

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

View File

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

View File

@@ -8,15 +8,17 @@
import { PluginStateSnapshotManager } from '../../mol-plugin-state/manager/snapshots';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { Download, ParseCcp4, ParseCif } from '../../mol-plugin-state/transforms/data';
import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif, TrajectoryFromPDB } from '../../mol-plugin-state/transforms/model';
import { Download, ParseCif, ParseCcp4 } from '../../mol-plugin-state/transforms/data';
import { CoordinatesFromLammpstraj, CoordinatesFromXtc, CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromGRO, TrajectoryFromLammpsTrajData, TrajectoryFromMmCif, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../mol-plugin-state/transforms/model';
import { StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { VolumeFromCcp4, VolumeFromDensityServerCif } from '../../mol-plugin-state/transforms/volume';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginContext } from '../../mol-plugin/context';
import { StateObjectSelector } from '../../mol-state';
import { PluginState } from '../../mol-plugin/state';
import { StateObjectSelector, StateTree } from '../../mol-state';
import { RuntimeContext, Task } from '../../mol-task';
import { MolViewSpec } from './behavior';
import { createPluginStateSnapshotCamera, modifyCanvasProps } from './camera';
import { createPluginStateSnapshotCamera, modifyCanvasProps, resetCanvasProps } from './camera';
import { MVSAnnotationsProvider } from './components/annotation-prop';
import { MVSAnnotationStructureComponent } from './components/annotation-structure-component';
import { MVSAnnotationTooltipsProvider } from './components/annotation-tooltips-prop';
@@ -24,15 +26,18 @@ import { CustomLabelProps, CustomLabelRepresentationProvider } from './component
import { CustomTooltipsProvider } from './components/custom-tooltips-prop';
import { IsMVSModelProps, IsMVSModelProvider } from './components/is-mvs-model-prop';
import { getPrimitiveStructureRefs, MVSBuildPrimitiveShape, MVSDownloadPrimitiveData, MVSInlinePrimitiveData, MVSShapeRepresentation3D } from './components/primitives';
import { MVSTrajectoryWithCoordinates } from './components/trajectory';
import { generateStateTransition } from './helpers/animation';
import { IsHiddenCustomStateExtension } from './load-extensions/is-hidden-custom-state';
import { NonCovalentInteractionsExtension } from './load-extensions/non-covalent-interactions';
import { LoadingActions, LoadingExtension, loadTreeVirtual, UpdateTarget } from './load-generic';
import { AnnotationFromSourceKind, AnnotationFromUriKind, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformAndInstantiateStructure, transformAndInstantiateVolume, volumeColorThemeForNode, volumeRepresentationProps } from './load-helpers';
import { MVSData, MVSData_States, SnapshotMetadata } from './mvs-data';
import { MVSData, MVSData_States, Snapshot, SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { validateTree } from './tree/generic/tree-schema';
import { convertMvsToMolstar, mvsSanityCheck } from './tree/molstar/conversion';
import { MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree, MolstarTreeSchema } from './tree/molstar/molstar-tree';
import { type MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
export interface MVSLoadOptions {
@@ -49,12 +54,24 @@ export interface MVSLoadOptions {
doNotReportErrors?: boolean
}
export function loadMVS(plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
const task = Task.create('Load MVS', ctx => _loadMVS(ctx, plugin, data, options));
return plugin.runTask(task);
}
/** Load a MolViewSpec (MVS) state(s) into the Mol* plugin as plugin state snapshots. */
export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
async function _loadMVS(ctx: RuntimeContext, plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
plugin.errorContext.clear('mvs');
try {
const mvsExtensionLoaded = plugin.state.hasBehavior(MolViewSpec);
if (!mvsExtensionLoaded) throw new Error('MolViewSpec extension is not loaded.');
// Stop any currently running audio
plugin.managers.markdownExtensions.audio.dispose();
// Reset canvas props to default so that modifyCanvasProps works as expected
resetCanvasProps(plugin);
// console.log(`MVS tree:\n${MVSData.toPrettyString(data)}`)
const multiData: MVSData_States = data.kind === 'multiple' ? data : MVSData.stateToStates(data);
const entries: PluginStateSnapshotManager.Entry[] = [];
@@ -68,11 +85,16 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
const entry = molstarTreeToEntry(
plugin,
molstarTree,
snapshot.root,
snapshot.animation,
{ ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms },
options
);
await assignStateTransition(ctx, plugin, entry, snapshot, options, i, multiData.snapshots.length);
entries.push(entry);
if (ctx.shouldUpdate) {
await ctx.update({ message: 'Loading MVS...', current: i, max: multiData.snapshots.length });
}
}
if (!options.appendSnapshots) {
plugin.managers.snapshot.clear();
@@ -80,6 +102,7 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
for (const entry of entries) {
plugin.managers.snapshot.add(entry);
}
if (entries.length > 0) {
await PluginCommands.State.Snapshots.Apply(plugin, { id: entries[0].snapshot.id });
}
@@ -101,24 +124,65 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
}
}
async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext, parentEntry: PluginStateSnapshotManager.Entry, parent: Snapshot, options: MVSLoadOptions, snapshotIndex: number, snapshotCount: number) {
const transitions = await generateStateTransition(ctx, parent, snapshotIndex, snapshotCount);
if (!transitions?.frames.length) return;
const animation: PluginState.StateTransition = {
autoplay: !!transitions.tree.params?.autoplay,
loop: !!transitions.tree.params?.loop,
frames: [],
};
for (let i = 0; i < transitions.frames.length; i++) {
const frame = transitions.frames[i];
const molstarTree = convertMvsToMolstar(frame[0], options.sourceUrl);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
parent.animation,
{ ...parent.metadata, previousTransitionDurationMs: transitions.frametimeMs },
options
);
StateTree.reuseTransformParams(entry.snapshot.data!.tree, parentEntry.snapshot.data!.tree);
animation.frames.push({
durationInMs: frame[1],
data: entry.snapshot.data!,
camera: transitions.tree.params?.include_camera ? entry.snapshot.camera : undefined,
canvas3d: transitions.tree.params?.include_canvas ? entry.snapshot.canvas3d : undefined,
});
if (ctx.shouldUpdate) {
await ctx.update({ message: `Loading animation for snapshot ${snapshotIndex + 1}/${snapshotCount}...`, current: i + 1, max: transitions.frames.length });
}
}
parentEntry.snapshot.transition = animation;
}
function molstarTreeToEntry(
plugin: PluginContext,
tree: MolstarTree,
mvsTree: MVSTree,
animation: MVSAnimationNode<'animation'> | undefined,
metadata: SnapshotMetadata & { previousTransitionDurationMs?: number },
options: { keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }
) {
const context = MolstarLoadingContext.create();
const snapshot = loadTreeVirtual(plugin, tree, MolstarLoadingActions, context, { replaceExisting: true, extensions: options?.extensions ?? BuiltinLoadingExtensions });
snapshot.canvas3d = {
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas, mvsTree.custom) : undefined,
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas, animation) : undefined,
};
if (!options?.keepCamera) {
snapshot.camera = createPluginStateSnapshotCamera(plugin, context, metadata);
}
snapshot.durationInMs = metadata.linger_duration_ms + (metadata.previousTransitionDurationMs ?? 0);
if (tree.custom?.molstar_on_load_markdown_commands) {
snapshot.onLoadMarkdownCommands = tree.custom.molstar_on_load_markdown_commands;
}
const entryParams: PluginStateSnapshotManager.EntryParams = {
key: metadata.key,
name: metadata.title,
@@ -165,31 +229,72 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
},
parse(updateParent: UpdateTarget, node: MolstarNode<'parse'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, ParseCif, {});
} else if (format === 'pdb') {
return updateParent;
} else if (format === 'map') {
return UpdateTarget.apply(updateParent, ParseCcp4, {});
} else {
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, ParseCif, {});
case 'pdb':
case 'pdbqt':
case 'gro':
case 'xyz':
case 'mol':
case 'sdf':
case 'mol2':
case 'xtc':
case 'lammpstrj':
return updateParent;
case 'map':
return UpdateTarget.apply(updateParent, ParseCcp4, {});
default:
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
}
},
coordinates(updateParent: UpdateTarget, node: MolstarNode<'coordinates'>): UpdateTarget | undefined {
const format = node.params.format;
switch (format) {
case 'xtc':
return UpdateTarget.apply(updateParent, CoordinatesFromXtc);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, CoordinatesFromLammpstraj);
default:
console.error(`Unknown format in "coordinates" node: "${format}"`);
return undefined;
}
},
trajectory(updateParent: UpdateTarget, node: MolstarNode<'trajectory'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
} else if (format === 'pdb') {
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, {});
} else {
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
case 'pdb':
case 'pdbqt':
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, { isPdbqt: format === 'pdbqt' });
case 'gro':
return UpdateTarget.apply(updateParent, TrajectoryFromGRO);
case 'xyz':
return UpdateTarget.apply(updateParent, TrajectoryFromXYZ);
case 'mol':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL);
case 'sdf':
return UpdateTarget.apply(updateParent, TrajectoryFromSDF);
case 'mol2':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL2);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, TrajectoryFromLammpsTrajData);
default:
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
}
},
trajectory_with_coordinates(updateParent: UpdateTarget, node: MolstarNode<'trajectory_with_coordinates'>): UpdateTarget | undefined {
const result = UpdateTarget.apply(updateParent, MVSTrajectoryWithCoordinates, {
coordinatesRef: node.params.coordinates_ref,
});
return UpdateTarget.setMvsDependencies(result, [node.params.coordinates_ref]);
},
model(updateParent: UpdateTarget, node: MolstarSubtree<'model'>, context: MolstarLoadingContext): UpdateTarget {
const annotations = collectAnnotationReferences(node, context);
const model = UpdateTarget.apply(updateParent, ModelFromTrajectory, {
@@ -312,7 +417,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
},
primitives(updateParent: UpdateTarget, tree: MolstarSubtree<'primitives'>, context: MolstarLoadingContext): UpdateTarget {
const refs = getPrimitiveStructureRefs(tree);
const data = UpdateTarget.apply(updateParent, MVSInlinePrimitiveData, { node: tree });
const data = UpdateTarget.apply(updateParent, MVSInlinePrimitiveData, { node: tree as any });
return applyPrimitiveVisuals(data, refs);
},
primitives_from_uri(updateParent: UpdateTarget, tree: MolstarNode<'primitives_from_uri'>, context: MolstarLoadingContext): UpdateTarget {

View File

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

View File

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

View File

@@ -6,10 +6,8 @@
*/
import * as iots from 'io-ts';
import { PathReporter } from 'io-ts/PathReporter';
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

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
import { deepClone, pickObjectKeys } from '../../../../mol-util/object';
import { GlobalMetadata, MVSData_State, Snapshot, SnapshotMetadata } from '../../mvs-data';
import { CustomProps } from '../generic/tree-schema';
import { MVSAnimationNodeParams, MVSAnimationSubtree } from '../animation/animation-tree';
import { MVSKind, MVSNode, MVSNodeParams, MVSSubtree } from './mvs-tree';
@@ -50,6 +51,8 @@ class _Base<TKind extends MVSKind> {
/** MVS builder pointing to the 'root' node */
export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
protected _animation: Animation | undefined = undefined;
constructor(params_: CustomAndRef) {
const { custom, ref } = params_;
const node: MVSNode<'root'> = { kind: 'root', custom, ref };
@@ -69,6 +72,7 @@ export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
return {
root: deepClone(this._node),
metadata: { ...metadata },
animation: this?._animation ? deepClone(this._animation.node) : undefined,
};
}
@@ -89,6 +93,43 @@ export class Root extends _Base<'root'> implements FocusMixin, PrimitivesMixin {
focus = bindMethod(this, FocusMixinImpl, 'focus');
primitives = bindMethod(this, PrimitivesMixinImpl, 'primitives');
primitives_from_uri = bindMethod(this, PrimitivesMixinImpl, 'primitives_from_uri');
animation(params: MVSAnimationNodeParams<'animation'> & CustomAndRef = {}): Animation {
this._animation ??= new Animation(params);
return this._animation;
}
/** Modifies custom state of the root */
extendRootCustomState(custom: Record<string, any>): this {
this._node.custom = { ...this._node.custom, ...custom };
return this;
}
}
export class Animation {
private _node: MVSAnimationSubtree<'animation'>;
constructor(
parameters: MVSAnimationNodeParams<'animation'> & CustomAndRef
) {
this._node = {
kind: 'animation',
children: [],
...splitParams<MVSAnimationNodeParams<'animation'>>(parameters),
};
}
get node(): MVSAnimationSubtree<'animation'> {
return this._node;
}
interpolate(params: MVSAnimationNodeParams<'interpolate'> & CustomAndRef): Animation {
const node = {
kind: 'interpolate',
...splitParams<MVSAnimationNodeParams<'interpolate'>>(params)
} as MVSAnimationSubtree<'interpolate'>;
this._node.children!.push(node);
return this;
}
}
@@ -103,10 +144,10 @@ export class Download extends _Base<'download'> {
/** Subsets of 'structure' node params which will be passed to individual builder functions. */
const StructureParamsSubsets = {
model: ['block_header', 'block_index', 'model_index'],
assembly: ['block_header', 'block_index', 'model_index', 'assembly_id'],
symmetry: ['block_header', 'block_index', 'model_index', 'ijk_min', 'ijk_max'],
symmetry_mates: ['block_header', 'block_index', 'model_index', 'radius'],
model: ['block_header', 'block_index', 'model_index', 'coordinates_ref'],
assembly: ['block_header', 'block_index', 'model_index', 'assembly_id', 'coordinates_ref'],
symmetry: ['block_header', 'block_index', 'model_index', 'ijk_min', 'ijk_max', 'coordinates_ref'],
symmetry_mates: ['block_header', 'block_index', 'model_index', 'radius', 'coordinates_ref'],
} satisfies { [kind in MVSNodeParams<'structure'>['type']]: (keyof MVSNodeParams<'structure'>)[] };
@@ -156,6 +197,11 @@ export class Parse extends _Base<'parse'> {
volume(params: MVSNodeParams<'volume'> & CustomAndRef = {}): Volume {
return new Volume(this._root, this.addChild('volume', params));
}
/** Add a 'coordinates' node indicating the parsed data type */
coordinates(params: MVSNodeParams<'coordinates'> & CustomAndRef = {}): Parse {
this.addChild('coordinates', params);
return this;
}
}

View File

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

View File

@@ -57,6 +57,8 @@ const TransformParams = SimpleParamsSchema({
rotation: OptionalField(Matrix, [1, 0, 0, 0, 1, 0, 0, 0, 1], 'Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation).'),
/** Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation). */
translation: OptionalField(Vector3, [0, 0, 0], 'Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation).'),
/** Point to rotate the object around. Can be either a 3D vector or dynamically computed object centroid. */
rotation_center: OptionalField(nullable(union(Vector3, literal('centroid'))), null, 'Point to rotate the object around. Can be either a 3D vector or dynamically computed object centroid.'),
/** Transform matrix (4x4 matrix flattened in column major format (j*4+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. Takes precedence over `rotation` and `translation`. */
matrix: OptionalField(nullable(Matrix), null, 'Transform matrix (4x4 matrix flattened in column major format (j*4+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. Takes precedence over `rotation` and `translation`.'),
});
@@ -90,6 +92,12 @@ export const MVSTreeSchema = TreeSchema({
format: RequiredField(ParseFormatT, 'Format of the input data resource.'),
}),
},
/** This node instructs to retrieve molecular coordinates from a parsed data resource. */
coordinates: {
description: 'This node instructs to retrieve molecular coordinates from a parsed data resource.',
parent: ['parse'],
params: SimpleParamsSchema({}),
},
/** This node instructs to create a structure from a parsed data resource. "Structure" refers to an internal representation of molecular coordinates without any visual representation. */
structure: {
description: 'This node instructs to create a structure from a parsed data resource. "Structure" refers to an internal representation of molecular coordinates without any visual representation.',
@@ -111,6 +119,8 @@ export const MVSTreeSchema = TreeSchema({
ijk_min: OptionalField(tuple([int, int, int]), [-1, -1, -1], 'Miller indices of the bottom-left unit cell to be included (only applies when `kind` is `"symmetry"`).'),
/** Miller indices of the top-right unit cell to be included (only applies when `kind` is `"symmetry"`). */
ijk_max: OptionalField(tuple([int, int, int]), [1, 1, 1], 'Miller indices of the top-right unit cell to be included (only applies when `kind` is `"symmetry"`).'),
/** Reference to a specific set of coordinates. */
coordinates_ref: OptionalField(nullable(str), null, 'Reference to a specific set of coordinates.')
}),
},
/** This node instructs to rotate and/or translate structure coordinates. */
@@ -322,7 +332,7 @@ export const MVSTreeSchema = TreeSchema({
parent: ['root'],
params: SimpleParamsSchema({
/** Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
background_color: RequiredField(ColorT, 'Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
background_color: OptionalField(ColorT, 'white', 'Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). Defaults to white.'),
}),
},
primitives: {

View File

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

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -11,6 +11,7 @@ import { CameraTransitionManager } from './camera/transition';
import { BehaviorSubject } from 'rxjs';
import { Scene } from '../mol-gl/scene';
import { assertUnreachable } from '../mol-util/type-helpers';
import { Ray3D } from '../mol-math/geometry/primitives/ray3d';
export type { ICamera };
@@ -26,6 +27,7 @@ interface ICamera {
readonly near: number,
readonly fogFar: number,
readonly fogNear: number,
readonly headRotation: Mat4,
}
const tmpClip = Vec4();
@@ -35,6 +37,7 @@ export class Camera implements ICamera {
readonly projection: Mat4 = Mat4.identity();
readonly projectionView: Mat4 = Mat4.identity();
readonly inverseProjectionView: Mat4 = Mat4.identity();
readonly headRotation: Mat4 = Mat4.zero();
readonly viewport: Viewport;
readonly state: Readonly<Camera.Snapshot> = Camera.createDefaultSnapshot();
@@ -69,7 +72,7 @@ export class Camera implements ICamera {
return false;
}
const height = 2 * Math.tan(snapshot.fov / 2) * Vec3.distance(snapshot.position, snapshot.target);
const height = 2 * Math.tan(snapshot.fov / 2) * Vec3.distance(snapshot.position, snapshot.target) * this.state.scale;
this.zoom = this.viewport.height / height;
updateClip(this);
@@ -191,6 +194,22 @@ export class Camera implements ICamera {
return (2 / w) / (rx * Math.abs(P00));
}
getRay(out: Ray3D, x: number, y: number) {
if (this.state.mode === 'orthographic') {
Vec3.set(out.origin, x, y, 0);
this.unproject(out.origin, out.origin);
Vec3.normalize(out.direction, Vec3.sub(out.direction, this.target, this.position));
Vec3.scaleAndAdd(out.origin, out.origin, out.direction, -this.near);
} else {
Vec3.copy(out.origin, this.state.position);
Vec3.scale(out.origin, out.origin, this.state.scale);
Vec3.set(out.direction, x, y, 0.5);
this.unproject(out.direction, out.direction);
Vec3.normalize(out.direction, Vec3.sub(out.direction, out.direction, out.origin));
}
return out;
}
constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128)) {
this.viewport = viewport;
Camera.copySnapshot(this.state, state);
@@ -270,6 +289,8 @@ export namespace Camera {
clipFar: true,
minNear: 5,
minFar: 0,
scale: 1,
};
}
@@ -287,6 +308,8 @@ export namespace Camera {
clipFar: boolean
minNear: number
minFar: number
scale: number
}
export function copySnapshot(out: Snapshot, source?: Partial<Snapshot>) {
@@ -306,6 +329,8 @@ export namespace Camera {
if (typeof source.minNear !== 'undefined') out.minNear = source.minNear;
if (typeof source.minFar !== 'undefined') out.minFar = source.minFar;
if (typeof source.scale !== 'undefined') out.scale = source.scale;
return out;
}
@@ -318,12 +343,26 @@ export namespace Camera {
&& a.clipFar === b.clipFar
&& a.minNear === b.minNear
&& a.minFar === b.minFar
&& a.scale === b.scale
&& Vec3.exactEquals(a.position, b.position)
&& Vec3.exactEquals(a.up, b.up)
&& Vec3.exactEquals(a.target, b.target);
}
}
const tmpPosition = Vec3();
const tmpTarget = Vec3();
function updateView(camera: Camera) {
if (camera.state.scale === 1) {
Mat4.lookAt(camera.view, camera.state.position, camera.state.target, camera.state.up);
} else {
Vec3.scale(tmpPosition, camera.state.position, camera.state.scale);
Vec3.scale(tmpTarget, camera.state.target, camera.state.scale);
Mat4.lookAt(camera.view, tmpPosition, tmpTarget, camera.state.up);
}
}
function updateOrtho(camera: Camera) {
const { viewport, zoom, near, far, viewOffset } = camera;
@@ -357,7 +396,7 @@ function updateOrtho(camera: Camera) {
Mat4.ortho(camera.projection, left, right, top, bottom, near, far);
// build view matrix
Mat4.lookAt(camera.view, camera.position, camera.target, camera.up);
updateView(camera);
}
function updatePers(camera: Camera) {
@@ -381,15 +420,23 @@ function updatePers(camera: Camera) {
Mat4.perspective(camera.projection, left, left + width, top, top - height, near, far);
// build view matrix
Mat4.lookAt(camera.view, camera.position, camera.target, camera.up);
updateView(camera);
}
function updateClip(camera: Camera) {
let { radius, radiusMax, mode, fog, clipFar, minNear, minFar } = camera.state;
if (radius < 0.01) radius = 0.01;
let { radius, radiusMax, mode, fog, clipFar, minNear, minFar, scale } = camera.state;
radiusMax *= scale;
minFar *= scale;
minNear *= scale;
radius *= scale;
const minRadius = 0.01 * scale;
if (radius < minRadius) radius = minRadius;
const normalizedFar = Math.max(clipFar ? radius : radiusMax, minFar);
const cameraDistance = Vec3.distance(camera.position, camera.target);
Vec3.scale(tmpTarget, camera.state.target, scale);
Vec3.scale(tmpPosition, camera.state.position, scale);
const cameraDistance = Vec3.distance(tmpPosition, tmpTarget);
let near = cameraDistance - radius;
let far = cameraDistance + normalizedFar;
@@ -405,7 +452,7 @@ function updateClip(camera: Camera) {
if (near === far) {
// make sure near and far are not identical to avoid Infinity in the projection matrix
far = near + 0.01;
far = near + 0.01 * scale;
}
const fogNearFactor = -(50 - fog) / 50;

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>
@@ -61,6 +61,7 @@ class EyeCamera implements ICamera {
projection = Mat4();
projectionView = Mat4();
inverseProjectionView = Mat4();
headRotation = Mat4();
state: Readonly<Camera.Snapshot> = Camera.createDefaultSnapshot();
viewOffset: Readonly<Camera.ViewOffset> = Camera.ViewOffset();
far: number = 0;
@@ -69,30 +70,29 @@ class EyeCamera implements ICamera {
fogNear: number = 0;
}
const eyeLeft = Mat4.identity(), eyeRight = Mat4.identity();
const tmpEyeLeft = Mat4.identity();
const tmpEyeRight = Mat4.identity();
function copyStates(parent: Camera, eye: EyeCamera) {
Viewport.copy(eye.viewport, parent.viewport);
Mat4.copy(eye.view, parent.view);
Mat4.copy(eye.projection, parent.projection);
Mat4.copy(eye.headRotation, parent.headRotation);
Camera.copySnapshot(eye.state, parent.state);
Camera.copyViewOffset(eye.viewOffset, parent.viewOffset);
eye.far = parent.far;
eye.near = parent.near;
eye.fogFar = parent.fogFar;
eye.fogNear = parent.fogNear;
}
//
function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right: EyeCamera) {
// Copy the states
Viewport.copy(left.viewport, camera.viewport);
Mat4.copy(left.view, camera.view);
Mat4.copy(left.projection, camera.projection);
Camera.copySnapshot(left.state, camera.state);
Camera.copyViewOffset(left.viewOffset, camera.viewOffset);
left.far = camera.far;
left.near = camera.near;
left.fogFar = camera.fogFar;
left.fogNear = camera.fogNear;
Viewport.copy(right.viewport, camera.viewport);
Mat4.copy(right.view, camera.view);
Mat4.copy(right.projection, camera.projection);
Camera.copySnapshot(right.state, camera.state);
Camera.copyViewOffset(right.viewOffset, camera.viewOffset);
right.far = camera.far;
right.near = camera.near;
right.fogFar = camera.fogFar;
right.fogNear = camera.fogNear;
copyStates(camera, left);
copyStates(camera, right);
// update the view offsets
@@ -112,8 +112,8 @@ function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right
// translate xOffset
eyeLeft[12] = -eyeSepHalf;
eyeRight[12] = eyeSepHalf;
tmpEyeLeft[12] = -eyeSepHalf;
tmpEyeRight[12] = eyeSepHalf;
// for left eye
@@ -123,7 +123,7 @@ function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right
left.projection[0] = 2 * camera.near / (xmax - xmin);
left.projection[8] = (xmax + xmin) / (xmax - xmin);
Mat4.mul(left.view, left.view, eyeLeft);
Mat4.mul(left.view, left.view, tmpEyeLeft);
Mat4.mul(left.projectionView, left.projection, left.view);
Mat4.invert(left.inverseProjectionView, left.projectionView);
@@ -135,7 +135,7 @@ function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right
right.projection[0] = 2 * camera.near / (xmax - xmin);
right.projection[8] = (xmax + xmin) / (xmax - xmin);
Mat4.mul(right.view, right.view, eyeRight);
Mat4.mul(right.view, right.view, tmpEyeRight);
Mat4.mul(right.projectionView, right.projection, right.view);
Mat4.invert(right.inverseProjectionView, right.projectionView);
}

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';
@@ -28,13 +28,12 @@ import { SetUtils } from '../mol-util/set';
import { Canvas3dInteractionHelper, Canvas3dInteractionHelperParams } from './helper/interaction-events';
import { PostprocessingParams } from './passes/postprocessing';
import { MultiSampleHelper, MultiSampleParams, MultiSamplePass } from './passes/multi-sample';
import { PickData } from './passes/pick';
import { PickHelper } from './passes/pick';
import { AsyncPickData, DefaultPickOptions, PickData } from './passes/pick';
import { PickHelper } from './helper/pick-helper';
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';
@@ -47,7 +46,13 @@ import { deepClone } from '../mol-util/object';
import { HiZParams, HiZPass } from './passes/hi-z';
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' }),
@@ -57,12 +62,11 @@ export const Canvas3DParams = {
off: PD.Group({})
}, { cycle: true, hideIf: p => p?.mode !== 'perspective' }),
fov: PD.Numeric(45, { min: 10, max: 130, step: 1 }, { label: 'Field of View' }),
scale: PD.Numeric(1, { min: 0.001, max: 1, step: 0.001 }, { label: 'Scene scale' }),
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({
@@ -110,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 */
@@ -330,7 +339,8 @@ interface Canvas3D {
pause(noDraw?: boolean): void
/** Sets drawPaused = false without starting the built in animation loop */
resume(): void
identify(x: number, y: number): PickData | undefined
identify(target: Vec2 | Ray3D): PickData | undefined
asyncIdentify(target: Vec2 | Ray3D): AsyncPickData | undefined
mark(loci: Representation.Loci, action: MarkerAction): void
getLoci(pickingId: PickingId | undefined): Representation.Loci
@@ -355,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']
@@ -374,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>();
@@ -412,18 +424,24 @@ namespace Canvas3D {
clipFar: p.cameraClipping.far,
minNear: p.cameraClipping.minNear,
fov: degToRad(p.camera.fov),
scale: p.camera.scale,
}, { 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);
const renderer = Renderer.create(webgl, p.renderer);
renderer.setOcclusionTest(hiZ.isOccluded);
const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height }, p.pickPadding);
const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, controls, p.interaction);
const pickOptions = {
pickPadding: p.pickPadding,
maxAsyncReadLag: DefaultPickOptions.maxAsyncReadLag,
};
const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height }, pickOptions);
const rayHelper = new RayHelper(webgl, renderer, scene, helper, pickOptions);
const interactionHelper = new Canvas3dInteractionHelper(identify, asyncIdentify, getLoci, input, camera, controls, p.interaction);
const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
@@ -649,9 +667,26 @@ namespace Canvas3D {
animationFrameHandle = 0;
}
function identify(x: number, y: number): PickData | undefined {
const cam = p.camera.stereo.name === 'on' ? stereoCamera : camera;
return webgl.isContextLost ? undefined : pickHelper.identify(x, y, cam);
function identify(target: Vec2 | Ray3D): PickData | undefined {
if (webgl.isContextLost) return undefined;
if ('origin' in target) {
return rayHelper.identify(target, camera);
} else {
const cam = (p.camera.stereo.name === 'on') ? stereoCamera : camera;
return pickHelper.identify(target[0], target[1], cam);
}
}
function asyncIdentify(target: Vec2 | Ray3D): AsyncPickData | undefined {
if (webgl.isContextLost) return undefined;
if ('origin' in target) {
return rayHelper.asyncIdentify(target, camera);
} else {
const cam = (p.camera.stereo.name === 'on') ? stereoCamera : camera;
return pickHelper.asyncIdentify(target[0], target[1], cam);
}
}
function commit(isSynchronous: boolean = false) {
@@ -841,6 +876,7 @@ namespace Canvas3D {
helper: { ...helper.camera.props },
stereo: { ...p.camera.stereo },
fov: Math.round(radToDeg(camera.state.fov)),
scale: camera.state.scale,
manualReset: !!p.camera.manualReset
},
cameraFog: camera.state.fog > 0
@@ -875,6 +911,10 @@ namespace Canvas3D {
});
const contextRestoredSub = contextRestored.subscribe(() => {
pickHelper.reset();
rayHelper.reset();
hiZ.reset();
scene.forEach(r => {
if (r.values.meta?.ref.value.reset) {
r.values.meta.ref.value.reset();
@@ -952,7 +992,7 @@ namespace Canvas3D {
input.click.subscribe(e => {
if (!e.modifiers.control || e.button !== 2) return;
const p = identify(e.x, e.y);
const p = identify(Vec2.create(e.x, e.y));
if (!p) {
occlusionLoci = undefined;
printOcclusion(occlusionLoci);
@@ -1020,6 +1060,7 @@ namespace Canvas3D {
pause,
resume: () => { drawPaused = false; },
identify,
asyncIdentify,
mark,
getLoci,
@@ -1060,6 +1101,9 @@ namespace Canvas3D {
if (props.camera && props.camera.fov !== undefined && props.camera.fov !== oldFov) {
cameraState.fov = degToRad(props.camera.fov);
}
if (props.camera && props.camera.scale !== undefined && props.camera.scale !== cameraState.scale) {
cameraState.scale = props.camera.scale;
}
if (props.cameraFog !== undefined && props.cameraFog.params) {
const newFog = props.cameraFog.name === 'on' ? props.cameraFog.params.intensity : 0;
if (newFog !== camera.state.fog) cameraState.fog = newFog;
@@ -1143,6 +1187,9 @@ namespace Canvas3D {
get props() {
return getProps();
},
get attribs() {
return a;
},
get input() {
return input;
},
@@ -1170,6 +1217,9 @@ namespace Canvas3D {
renderer.dispose();
interactionHelper.dispose();
hiZ.dispose();
pickHelper.dispose();
rayHelper.dispose();
if (fenceSync !== null) {
webgl.deleteSync(fenceSync);
fenceSync = null;

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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -14,14 +14,14 @@ import { Camera } from '../camera';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Bond } from '../../mol-model/structure';
import { TrackballControls } from '../controls/trackball';
import { Ray3D } from '../../mol-math/geometry/primitives/ray3d';
import { AsyncPickData } from '../passes/pick';
type Canvas3D = import('../canvas3d').Canvas3D
type HoverEvent = import('../canvas3d').Canvas3D.HoverEvent
type DragEvent = import('../canvas3d').Canvas3D.DragEvent
type ClickEvent = import('../canvas3d').Canvas3D.ClickEvent
enum InputEvent { Move, Click, Drag }
const tmpPosA = Vec3();
const tmpPos = Vec3();
const tmpNorm = Vec3();
@@ -29,6 +29,7 @@ const tmpNorm = Vec3();
export const Canvas3dInteractionHelperParams = {
maxFps: PD.Numeric(30, { min: 10, max: 60, step: 10 }),
preferAtomPixelPadding: PD.Numeric(3, { min: 0, max: 20, step: 1 }, { description: 'Number of extra pixels at which to prefer atoms over bonds.' }),
convertCoordsToRay: PD.Boolean(false, { description: 'Convert screen coordinates to ray for picking.' }),
};
export type Canvas3dInteractionHelperParams = typeof Canvas3dInteractionHelperParams
export type Canvas3dInteractionHelperProps = PD.Values<Canvas3dInteractionHelperParams>
@@ -47,10 +48,11 @@ export class Canvas3dInteractionHelper {
private endX = -1;
private endY = -1;
private id: PickingId | undefined = void 0;
private ray: Ray3D | undefined = void 0;
private pickData: AsyncPickData | undefined = void 0;
private position: Vec3 | undefined = void 0;
private currentIdentifyT = 0;
private isInteracting = false;
private prevLoci: Representation.Loci = Representation.Loci.Empty;
@@ -68,46 +70,66 @@ export class Canvas3dInteractionHelper {
Object.assign(this.props, props);
}
private identify(e: InputEvent, t: number) {
const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
if (e === InputEvent.Drag) {
if (xyChanged && !this.outsideViewport(this.startX, this.startY)) {
this.events.drag.next({ current: this.prevLoci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, pageStart: Vec2.create(this.startX, this.startY), pageEnd: Vec2.create(this.endX, this.endY) });
this.startX = this.endX;
this.startY = this.endY;
}
return;
private getTarget(): Vec2 | Ray3D {
if (this.ray) {
return this.ray;
} else if (this.props.convertCoordsToRay) {
return this.camera.getRay(Ray3D(), this.endX, this.input.height - this.endY);
} else {
return Vec2.create(this.endX, this.endY);
}
}
private handleMove() {
const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
if (xyChanged) {
const pickData = this.canvasIdentify(this.endX, this.endY);
this.id = pickData?.id;
this.position = pickData?.position;
this.pickData = this.canvasAsyncIdentify(this.getTarget());
this.startX = this.endX;
this.startY = this.endY;
}
}
if (e === InputEvent.Click) {
const loci = this.getLoci(this.id, this.position);
this.events.click.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
this.prevLoci = loci;
return;
}
if (!this.inside || this.currentIdentifyT !== t || !xyChanged || this.outsideViewport(this.endX, this.endY)) return;
const loci = this.getLoci(this.id, this.position);
this.events.hover.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
private handleClick() {
const pickData = this.canvasIdentify(this.getTarget());
const loci = this.getLoci(pickData?.id, pickData?.position);
this.events.click.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: pickData?.position });
this.prevLoci = loci;
}
private handleDrag() {
const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
if (xyChanged && !this.outsideViewport(this.startX, this.startY, this.ray)) {
this.events.drag.next({ current: this.prevLoci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, pageStart: Vec2.create(this.startX, this.startY), pageEnd: Vec2.create(this.endX, this.endY) });
this.startX = this.endX;
this.startY = this.endY;
}
}
tick(t: number) {
if (this.inside && t - this.prevT > 1000 / this.props.maxFps) {
if (!this.inside) return;
if (this.pickData) {
const pickData = this.pickData.tryGet();
if (pickData !== 'pending') {
this.position = pickData?.position;
if (this.inside) {
const loci = this.getLoci(pickData?.id, pickData?.position);
this.events.hover.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: pickData?.position });
this.prevLoci = loci;
}
this.pickData = undefined;
}
}
if (t - this.prevT > 1000 / this.props.maxFps) {
this.prevT = t;
this.currentIdentifyT = t;
this.identify(this.isInteracting ? InputEvent.Drag : InputEvent.Move, t);
if (this.isInteracting) {
this.handleDrag();
} else {
this.handleMove();
}
}
}
@@ -119,22 +141,24 @@ export class Canvas3dInteractionHelper {
}
}
private move(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys) {
private move(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, ray?: Ray3D) {
this.inside = true;
this.buttons = buttons;
this.button = button;
this.modifiers = modifiers;
this.ray = ray;
this.endX = x;
this.endY = y;
}
private click(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys) {
private click(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, ray?: Ray3D) {
this.endX = x;
this.endY = y;
this.buttons = buttons;
this.button = button;
this.modifiers = modifiers;
this.identify(InputEvent.Click, 0);
this.ray = ray;
this.handleClick();
}
private drag(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys) {
@@ -143,7 +167,7 @@ export class Canvas3dInteractionHelper {
this.buttons = buttons;
this.button = button;
this.modifiers = modifiers;
this.identify(InputEvent.Drag, 0);
this.handleDrag();
}
private modify(modifiers: ModifiersKeys) {
@@ -152,7 +176,9 @@ export class Canvas3dInteractionHelper {
this.events.hover.next({ current: this.prevLoci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
}
private outsideViewport(x: number, y: number) {
private outsideViewport(x: number, y: number, ray?: Ray3D) {
if (ray) return false;
const { input, camera: { viewport } } = this;
x *= input.pixelRatio;
y *= input.pixelRatio;
@@ -189,7 +215,7 @@ export class Canvas3dInteractionHelper {
this.ev.dispose();
}
constructor(private canvasIdentify: Canvas3D['identify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private controls: TrackballControls, props: Partial<Canvas3dInteractionHelperProps> = {}) {
constructor(private canvasIdentify: Canvas3D['identify'], private canvasAsyncIdentify: Canvas3D['asyncIdentify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private controls: TrackballControls, props: Partial<Canvas3dInteractionHelperProps> = {}) {
this.props = { ...PD.getDefaultValues(Canvas3dInteractionHelperParams), ...props };
input.drag.subscribe(({ x, y, buttons, button, modifiers }) => {
@@ -198,14 +224,14 @@ export class Canvas3dInteractionHelper {
this.drag(x, y, buttons, button, modifiers);
});
input.move.subscribe(({ x, y, inside, buttons, button, modifiers, onElement }) => {
input.move.subscribe(({ x, y, inside, buttons, button, modifiers, onElement, ray }) => {
if (!inside || this.isInteracting) return;
if (!onElement) {
this.leave();
return;
}
// console.log('move');
this.move(x, y, buttons, button, modifiers);
this.move(x, y, buttons, button, modifiers, ray);
});
input.leave.subscribe(() => {
@@ -213,10 +239,10 @@ export class Canvas3dInteractionHelper {
this.leave();
});
input.click.subscribe(({ x, y, buttons, button, modifiers }) => {
if (this.outsideViewport(x, y)) return;
input.click.subscribe(({ x, y, buttons, button, modifiers, ray }) => {
if (this.outsideViewport(x, y, ray)) return;
// console.log('click');
this.click(x, y, buttons, button, modifiers);
this.click(x, y, buttons, button, modifiers, ray);
});
input.interactionEnd.subscribe(() => {

View File

@@ -0,0 +1,208 @@
/**
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Renderer } from '../../mol-gl/renderer';
import { Scene } from '../../mol-gl/scene';
import { WebGLContext } from '../../mol-gl/webgl/context';
import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
import { spiral2d } from '../../mol-math/misc';
import { isTimingMode } from '../../mol-util/debug';
import { Camera } from '../camera';
import { StereoCamera } from '../camera/stereo';
import { cameraUnproject, Viewport } from '../camera/util';
import { Helper } from '../helper/helper';
import { AsyncPickData, AsyncPickStatus, checkAsyncPickingSupport, PickBuffers, PickData, PickOptions, PickPass } from '../passes/pick';
export class PickHelper {
dirty = true;
private pickPadding: number;
private buffers = new PickBuffers(this.webgl, this.pickPass);
private viewport = Viewport();
private pickRatio: number;
private pickX: number;
private pickY: number;
private pickWidth: number;
private pickHeight: number;
private halfPickWidth: number;
private spiral: [number, number][];
setViewport(x: number, y: number, width: number, height: number) {
Viewport.set(this.viewport, x, y, width, height);
this.update();
}
setPickPadding(pickPadding: number) {
if (this.pickPadding !== pickPadding) {
this.pickPadding = pickPadding;
this.update();
}
}
private update() {
const { x, y, width, height } = this.viewport;
this.pickRatio = this.pickPass.pickRatio;
this.pickX = Math.ceil(x * this.pickRatio);
this.pickY = Math.ceil(y * this.pickRatio);
const pickWidth = Math.floor(width * this.pickRatio);
const pickHeight = Math.floor(height * this.pickRatio);
if (pickWidth !== this.pickWidth || pickHeight !== this.pickHeight) {
this.pickWidth = pickWidth;
this.pickHeight = pickHeight;
this.halfPickWidth = Math.floor(this.pickWidth / 2);
this.buffers.setViewport(this.pickX, this.pickY, this.pickWidth, this.pickHeight);
}
this.spiral = spiral2d(Math.ceil(this.pickRatio * this.pickPadding));
this.dirty = true;
}
private render(camera: Camera | StereoCamera) {
if (isTimingMode) this.webgl.timer.mark('PickHelper.render', { captureStats: true });
const { pickX, pickY, pickWidth, pickHeight, halfPickWidth } = this;
const { renderer, scene, helper } = this;
renderer.setTransparentBackground(false);
renderer.setDrawingBufferSize(pickWidth, pickHeight);
renderer.setPixelRatio(this.pickRatio);
if (StereoCamera.is(camera)) {
renderer.setViewport(pickX, pickY, halfPickWidth, pickHeight);
this.pickPass.render(renderer, camera.left, scene, helper);
renderer.setViewport(pickX + halfPickWidth, pickY, pickWidth - halfPickWidth, pickHeight);
this.pickPass.render(renderer, camera.right, scene, helper);
} else {
renderer.setViewport(pickX, pickY, pickWidth, pickHeight);
this.pickPass.render(renderer, camera, scene, helper);
}
this.dirty = false;
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.render');
}
private identifyInternal(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
if (this.webgl.isContextLost) return;
const { webgl, pickRatio } = this;
if (webgl.isContextLost) return;
x *= webgl.pixelRatio;
y *= webgl.pixelRatio;
y = this.pickPass.drawingBufferHeight - y; // flip y
const { viewport } = this;
// check if within viewport
if (x < viewport.x ||
y < viewport.y ||
x > viewport.x + viewport.width ||
y > viewport.y + viewport.height
) return;
const xv = x - viewport.x;
const yv = y - viewport.y;
const xp = Math.floor(xv * pickRatio);
const yp = Math.floor(yv * pickRatio);
const pickingId = this.buffers.getPickingId(xp, yp);
if (pickingId === undefined) return;
const z = this.buffers.getDepth(xp, yp);
const position = Vec3.create(x, y, z);
if (StereoCamera.is(camera)) {
const halfWidth = Math.floor(viewport.width / 2);
if (x > viewport.x + halfWidth) {
position[0] = viewport.x + (xv - halfWidth) * 2;
cameraUnproject(position, position, viewport, camera.right.inverseProjectionView);
} else {
position[0] = viewport.x + xv * 2;
cameraUnproject(position, position, viewport, camera.left.inverseProjectionView);
}
} else {
cameraUnproject(position, position, viewport, camera.inverseProjectionView);
}
return { id: pickingId, position };
}
private prepare() {
if (this.pickRatio !== this.pickPass.pickRatio) {
this.update();
}
}
private getPickData(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
for (const d of this.spiral) {
const pickData = this.identifyInternal(x + d[0], y + d[1], camera);
if (pickData) return pickData;
}
}
identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
this.prepare();
if (this.dirty) {
if (isTimingMode) this.webgl.timer.mark('PickHelper.identify');
this.render(camera);
this.buffers.read();
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.identify');
}
return this.getPickData(x, y, camera);
}
asyncIdentify(x: number, y: number, camera: Camera | StereoCamera): AsyncPickData | undefined {
this.prepare();
if (this.dirty) {
if (isTimingMode) this.webgl.timer.mark('PickHelper.asyncIdentify');
this.render(camera);
this.buffers.asyncRead();
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.asyncIdentify');
}
return {
tryGet: () => {
const status = this.buffers.check();
if (status === AsyncPickStatus.Resolved) {
return this.getPickData(x, y, camera);
} else if (status === AsyncPickStatus.Pending) {
return 'pending';
} else if (status === AsyncPickStatus.Failed) {
this.dirty = true;
}
}
};
}
reset() {
this.buffers.reset();
this.dirty = true;
}
dispose() {
this.buffers.dispose();
}
constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport, options: PickOptions) {
this.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
this.pickPadding = options.pickPadding;
if (!checkAsyncPickingSupport(webgl)) {
this.asyncIdentify = (x, y, camera) => ({
tryGet: () => this.identify(x, y, camera)
});
}
}
}

View File

@@ -0,0 +1,205 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Renderer } from '../../mol-gl/renderer';
import { Scene } from '../../mol-gl/scene';
import { WebGLContext } from '../../mol-gl/webgl/context';
import { Ray3D } from '../../mol-math/geometry/primitives/ray3d';
import { Mat4, Quat, Vec3 } from '../../mol-math/linear-algebra';
import { degToRad, spiral2d } from '../../mol-math/misc';
import { isTimingMode } from '../../mol-util/debug';
import { Camera } from '../camera';
import { cameraUnproject } from '../camera/util';
import { Viewport } from '../camera/util';
import { Helper } from './helper';
import { AsyncPickData, PickBuffers, PickData, PickPass, PickOptions, checkAsyncPickingSupport, AsyncPickStatus } from '../passes/pick';
import { Sphere3D } from '../../mol-math/geometry/primitives/sphere3d';
export class RayHelper {
private viewport = Viewport();
private size: number;
private spiral: [number, number][];
private pickPadding: number;
private camera: Camera;
private pickPass: PickPass;
private buffers: PickBuffers;
setPickPadding(pickPadding: number) {
if (this.pickPadding !== pickPadding) {
this.pickPadding = pickPadding;
this.update();
}
}
private update() {
const size = this.pickPadding * 2 + 1;
Viewport.set(this.viewport, 0, 0, size, size);
this.buffers.setViewport(0, 0, size, size);
this.spiral = spiral2d(this.pickPadding);
this.size = size;
this.pickPass.setSize(size, size);
}
private render(camera: Camera) {
if (isTimingMode) this.webgl.timer.mark('RayHelper.render', { captureStats: true });
const { renderer, scene, helper } = this;
renderer.setTransparentBackground(false);
renderer.setDrawingBufferSize(this.size, this.size);
renderer.setPixelRatio(1);
renderer.setViewport(0, 0, this.size, this.size);
this.pickPass.render(renderer, camera, scene, helper);
if (isTimingMode) this.webgl.timer.markEnd('RayHelper.render');
}
private identifyInternal(x: number, y: number): PickData | undefined {
if (this.webgl.isContextLost) return;
const { viewport } = this;
const pickingId = this.buffers.getPickingId(x, y);
if (pickingId === undefined) return;
const z = this.buffers.getDepth(x, y);
const position = Vec3.create(x, y, z);
cameraUnproject(position, position, viewport, this.camera.inverseProjectionView);
return { id: pickingId, position };
}
private prepare(ray: Ray3D, cam: Camera) {
this.camera.far = cam.far;
this.camera.near = cam.near;
this.camera.fogFar = cam.fogFar;
this.camera.fogNear = cam.fogNear;
Viewport.copy(this.camera.viewport, this.viewport);
Camera.copySnapshot(this.camera.state, { ...cam.state, mode: 'orthographic' });
updateOrthoRayCamera(this.camera, ray);
Mat4.mul(this.camera.projectionView, this.camera.projection, this.camera.view);
Mat4.tryInvert(this.camera.inverseProjectionView, this.camera.projectionView);
}
private getPickData(): PickData | undefined {
const c = this.pickPadding;
for (const d of this.spiral) {
const pickData = this.identifyInternal(c + d[0], c + d[1]);
if (pickData) return pickData;
}
}
sphere = Sphere3D();
private intersectsScene(ray: Ray3D, scale: number): boolean {
Sphere3D.scaleNX(this.sphere, this.scene.boundingSphereVisible, scale);
return Ray3D.isInsideSphere3D(ray, this.sphere) || Ray3D.isIntersectingSphere3D(ray, this.sphere);
}
identify(ray: Ray3D, cam: Camera): PickData | undefined {
if (!this.intersectsScene(ray, cam.state.scale)) return;
this.prepare(ray, cam);
if (isTimingMode) this.webgl.timer.mark('RayHelper.identify');
this.render(this.camera);
this.buffers.read();
if (isTimingMode) this.webgl.timer.markEnd('RayHelper.identify');
return this.getPickData();
}
asyncIdentify(ray: Ray3D, cam: Camera): AsyncPickData | undefined {
if (!this.intersectsScene(ray, cam.state.scale)) return;
this.prepare(ray, cam);
if (isTimingMode) this.webgl.timer.mark('RayHelper.asyncIdentify');
this.render(this.camera);
this.buffers.asyncRead();
if (isTimingMode) this.webgl.timer.markEnd('RayHelper.asyncIdentify');
return {
tryGet: () => {
const status = this.buffers.check();
if (status === AsyncPickStatus.Resolved) {
return this.getPickData();
} else if (status === AsyncPickStatus.Pending) {
return 'pending';
}
}
};
}
reset() {
this.buffers.reset();
this.pickPass.reset();
}
dispose() {
this.buffers.dispose();
this.pickPass.dispose();
}
constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, options: PickOptions) {
const size = options.pickPadding * 2 + 1;
this.camera = new Camera();
this.pickPass = new PickPass(webgl, size, size, 1);
this.buffers = new PickBuffers(this.webgl, this.pickPass, options.maxAsyncReadLag);
this.pickPadding = options.pickPadding;
this.update();
if (!checkAsyncPickingSupport(webgl)) {
this.asyncIdentify = (ray, cam) => ({
tryGet: () => this.identify(ray, cam)
});
}
}
}
//
function updateOrthoRayCamera(camera: Camera, ray: Ray3D) {
const { near, far, viewport } = camera;
const height = 2 * Math.tan(degToRad(0.1) / 2) * Vec3.distance(camera.position, camera.target) * camera.state.scale;
const zoom = viewport.height / height;
const fullLeft = -viewport.width / 2;
const fullRight = viewport.width / 2;
const fullTop = viewport.height / 2;
const fullBottom = -viewport.height / 2;
const dx = (fullRight - fullLeft) / (2 * zoom);
const dy = (fullTop - fullBottom) / (2 * zoom);
const cx = (fullRight + fullLeft) / 2;
const cy = (fullTop + fullBottom) / 2;
const left = cx - dx;
const right = cx + dx;
const top = cy + dy;
const bottom = cy - dy;
// build projection matrix
Mat4.ortho(camera.projection, left, right, top, bottom, near, far);
const direction = Vec3.normalize(Vec3(), ray.direction);
const r = Quat.fromUnitVec3(Quat(), direction, Vec3.negUnitZ);
Quat.invert(r, r);
const eye = Vec3.clone(ray.origin);
const up = Vec3.transformQuat(Vec3(), Vec3.unitY, r);
const target = Vec3.add(Vec3(), eye, direction);
// build view matrix
Mat4.lookAt(camera.view, eye, target, up);
}

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>
*/
@@ -26,6 +26,7 @@ import { Vec4 } from '../../mol-math/linear-algebra/3d/vec4';
import { degToRad, isPowerOfTwo } from '../../mol-math/misc';
import { Mat3 } from '../../mol-math/linear-algebra/3d/mat3';
import { Euler } from '../../mol-math/linear-algebra/3d/euler';
import { PostprocessingProps } from './postprocessing';
const SharedParams = {
opacity: PD.Numeric(1, { min: 0.0, max: 1.0, step: 0.01 }),
@@ -172,10 +173,14 @@ export class BackgroundPass {
}
const m = this.renderable.values.uViewDirectionProjectionInverse.ref.value;
Vec3.sub(this.dir, cam.state.position, cam.state.target);
Vec3.setMagnitude(this.dir, this.dir, 0.1);
Vec3.copy(this.position, this.dir);
Mat4.lookAt(m, this.position, this.target, cam.state.up);
if (Mat4.isZero(camera.headRotation)) {
Vec3.sub(this.dir, cam.state.position, cam.state.target);
Vec3.setMagnitude(this.dir, this.dir, 0.1);
Vec3.copy(this.position, this.dir);
Mat4.lookAt(m, this.position, this.target, cam.state.up);
} else {
Mat4.invert(m, camera.headRotation);
}
Mat4.mul(m, cam.projection, m);
Mat4.invert(m, m);
ValueCell.update(this.renderable.values.uViewDirectionProjectionInverse, m);
@@ -292,7 +297,7 @@ export class BackgroundPass {
ValueCell.update(this.renderable.values.uViewport, Vec4.set(this.renderable.values.uViewport.ref.value, x, y, width, height));
}
isEnabled(props: BackgroundProps) {
private _isEnabled(props: BackgroundProps) {
return !!(
(this.skybox && this.skybox.loaded) ||
(this.image && this.image.loaded) ||
@@ -301,6 +306,10 @@ export class BackgroundPass {
);
}
isEnabled(props: PostprocessingProps) {
return props.enabled && this._isEnabled(props.background);
}
private isReady() {
return !!(
(this.skybox && this.skybox.loaded) ||
@@ -315,7 +324,7 @@ export class BackgroundPass {
clear(props: BackgroundProps, transparentBackground: boolean, backgroundColor: Color) {
const { gl, state } = this.webgl;
if (this.isEnabled(props)) {
if (this._isEnabled(props)) {
if (transparentBackground) {
state.clearColor(0, 0, 0, 0);
} else {
@@ -332,7 +341,7 @@ export class BackgroundPass {
}
render(props: BackgroundProps) {
if (!this.isEnabled(props) || !this.isReady()) return;
if (!this._isEnabled(props) || !this.isReady()) return;
if (this.renderable.values.dVariant.ref.value === 'image') {
this.updateImageScaling();

View File

@@ -38,7 +38,7 @@ export type BloomProps = PD.Values<typeof BloomParams>
export class BloomPass {
static isEnabled(props: PostprocessingProps) {
return props.bloom.name === 'on';
return props.enabled && props.bloom.name === 'on';
}
readonly emissiveTarget: RenderTarget;

View File

@@ -37,7 +37,7 @@ export type DofProps = PD.Values<typeof DofParams>
export class DofPass {
static isEnabled(props: PostprocessingProps) {
return props.dof.name !== 'off';
return props.enabled && props.dof.name !== 'off';
}
readonly target: RenderTarget;
@@ -119,18 +119,18 @@ export class DofPass {
needsUpdate = true;
}
const wolrdCenter = (props.center === 'scene-center' ? sphere.center : camera.state.target);
const distance = Vec3.distance(camera.state.position, wolrdCenter);
const worldCenter = (props.center === 'scene-center' ? sphere.center : camera.state.target);
const distance = Vec3.distance(camera.state.position, worldCenter);
const inFocus = distance + props.inFocus;
ValueCell.updateIfChanged(this.renderable.values.uInFocus, inFocus);
ValueCell.updateIfChanged(this.renderable.values.uInFocus, inFocus * camera.state.scale);
// transform center in view space
const center = this.renderable.values.uCenter.ref.value;
Vec3.transformMat4(center, wolrdCenter, camera.view);
Vec3.transformMat4(center, worldCenter, camera.view);
ValueCell.update(this.renderable.values.uCenter, center);
ValueCell.updateIfChanged(this.renderable.values.uBlurSpread, props.blurSpread);
ValueCell.updateIfChanged(this.renderable.values.uPPM, props.PPM);
ValueCell.updateIfChanged(this.renderable.values.uPPM, props.PPM * camera.state.scale);
if (needsUpdate) {
this.renderable.update();

View File

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

View File

@@ -198,8 +198,10 @@ export class DrawPass {
const dpoitTextures = this.dpoit.bindDualDepthPeeling();
renderer.renderDpoitTransparent(scene.primitives, camera, this.depthTextureOpaque, dpoitTextures);
target.bind();
this.dpoit.renderBlendBack();
if (iterations > 1) {
target.bind();
this.dpoit.renderBlendBack();
}
if (isTimingMode) this.webgl.timer.markEnd('DpoitPass.layer');
}
@@ -377,6 +379,7 @@ export class DrawPass {
const antialiasingEnabled = AntialiasingPass.isEnabled(props.postprocessing);
const markingEnabled = MarkingPass.isEnabled(props.marking);
const dofEnabled = DofPass.isEnabled(props.postprocessing);
const bloomEnabled = BloomPass.isEnabled(props.postprocessing);
const { x, y, width, height } = camera.viewport;
renderer.setViewport(x, y, width, height);
@@ -446,7 +449,7 @@ export class DrawPass {
needsTargetCopy = true;
}
if (props.postprocessing.dof.name === 'on') {
if (dofEnabled && props.postprocessing.dof.name === 'on') {
const input = AntialiasingPass.isEnabled(props.postprocessing)
? this.antialiasing.target.texture
: PostprocessingPass.isEnabled(props.postprocessing)
@@ -469,7 +472,7 @@ export class DrawPass {
}
}
if (props.postprocessing.bloom.name === 'on') {
if (bloomEnabled && props.postprocessing.bloom.name === 'on') {
const emissiveBloom = props.postprocessing.bloom.params.mode === 'emissive';
if (emissiveBloom && scene.emissiveAverage > 0) {
@@ -493,7 +496,7 @@ export class DrawPass {
const { renderer, camera, scene, helper } = ctx;
this.postprocessing.setTransparentBackground(props.transparentBackground);
const transparentBackground = props.transparentBackground || this.postprocessing.background.isEnabled(props.postprocessing.background);
const transparentBackground = props.transparentBackground || this.postprocessing.background.isEnabled(props.postprocessing);
renderer.setTransparentBackground(transparentBackground);
renderer.setDrawingBufferSize(this.colorTarget.getWidth(), this.colorTarget.getHeight());

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023 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 Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -20,7 +20,7 @@ import { Camera } from '../camera';
import { Viewport } from '../camera/util';
import { DrawPass } from './draw';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { getBuffer } from '../../mol-gl/webgl/buffer';
import { PixelPackBuffer } from '../../mol-gl/webgl/buffer';
// avoiding namespace lookup improved performance in Chrome (Aug 2020)
const v3transformMat4 = Vec3.transformMat4;
@@ -128,7 +128,7 @@ export class HiZPass {
private readonly levelData: LevelData = [];
private readonly fb: Framebuffer;
private readonly buf: WebGLBuffer;
private readonly buf: PixelPackBuffer;
private readonly tex: Texture;
private readonly renderable: HiZRenderable;
private readonly supported: boolean;
@@ -221,10 +221,7 @@ export class HiZPass {
const hw = this.tex.getWidth();
const hh = this.tex.getHeight();
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.buf);
gl.bufferData(gl.PIXEL_PACK_BUFFER, this.buffer.byteLength, gl.STREAM_READ);
gl.readPixels(0, 0, hw, hh, gl.RED, gl.FLOAT, 0);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
this.buf.read(0, 0, hw, hh);
this.sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
gl.flush();
@@ -249,9 +246,7 @@ export class HiZPass {
this.frameLag += 1;
// console.log(`waiting for buffer data for ${this.frameLag} frames`);
} else {
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.buf);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this.buffer);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
this.buf.getSubData(this.buffer);
// console.log(`got buffer data after ${this.frameLag + 1} frames`);
gl.deleteSync(this.sync);
this.sync = null;
@@ -510,6 +505,16 @@ export class HiZPass {
//
reset() {
this.sync = null;
this.ready = false;
this.frameLag = 0;
this.levelData.length = 0;
const { x, y, width, height } = this.viewport;
this.setViewport(x, y, width, height);
}
dispose() {
if (!this.supported) return;
@@ -517,7 +522,7 @@ export class HiZPass {
this.fb.destroy();
this.tex.destroy();
this.webgl.gl.deleteBuffer(this.buf);
this.buf.destroy();
this.renderable.dispose();
for (const td of this.levelData) {
@@ -527,6 +532,8 @@ export class HiZPass {
}
constructor(private webgl: WebGLContext, private drawPass: DrawPass, canvas: HTMLCanvasElement | undefined, props: Partial<HiZProps>) {
this.props = { ...PD.getDefaultValues(HiZParams), ...props };
const { gl, extensions } = webgl;
if (!isWebGL2(gl) || !extensions.colorBufferFloat) {
if (isDebugMode) {
@@ -552,8 +559,7 @@ export class HiZPass {
}
this.supported = true;
this.props = { ...PD.getDefaultValues(HiZParams), ...props };
this.buf = getBuffer(gl);
this.buf = webgl.resources.pixelPack('alpha', 'float');
this.renderable = createHiZRenderable(webgl, this.drawPass.depthTextureOpaque);
if (isDebugMode && canvas) {

View File

@@ -33,6 +33,8 @@ import { JitterVectors, MultiSampleProps } from './multi-sample';
import { compose_frag as multiSample_compose_frag } from '../../mol-gl/shader/compose.frag';
import { clamp, lerp } from '../../mol-math/interpolate';
import { SsaoProps } from './ssao';
import { OutlinePass } from './outline';
import { BloomPass } from './bloom';
type Props = {
transparentBackground: boolean;
@@ -169,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');
}
@@ -313,8 +317,11 @@ export class IlluminationPass {
const orthographic = camera.state.mode === 'orthographic' ? 1 : 0;
const outlinesEnabled = props.postprocessing.outline.name === 'on' && !props.illumination.ignoreOutline;
const antialiasingEnabled = AntialiasingPass.isEnabled(props.postprocessing);
const outlinesEnabled = OutlinePass.isEnabled(props.postprocessing) && !props.illumination.ignoreOutline;
const occlusionEnabled = PostprocessingPass.isTransparentSsaoEnabled(scene, props.postprocessing);
const bloomEnabled = BloomPass.isEnabled(props.postprocessing);
const dofEnabled = DofPass.isEnabled(props.postprocessing);
const markingEnabled = MarkingPass.isEnabled(props.marking);
const hasTransparent = scene.opacityAverage < 1;
@@ -327,7 +334,7 @@ export class IlluminationPass {
ValueCell.update(this.composeRenderable.values.dOutlineEnable, outlinesEnabled);
}
if (props.postprocessing.outline.name === 'on') {
if (outlinesEnabled && props.postprocessing.outline.name === 'on') {
const { transparentOutline, outlineScale } = this.drawPass.postprocessing.outline.update(camera, props.postprocessing.outline.params, this.drawPass.depthTargetTransparent.texture, this.drawPass.depthTextureOpaque);
this.drawPass.postprocessing.outline.render();
@@ -348,7 +355,7 @@ export class IlluminationPass {
ValueCell.update(this.composeRenderable.values.dOcclusionEnable, occlusionEnabled);
}
if (props.postprocessing.occlusion.name === 'on') {
if (occlusionEnabled && props.postprocessing.occlusion.name === 'on') {
ValueCell.update(this.composeRenderable.values.uOcclusionColor, Color.toVec3Normalized(this.composeRenderable.values.uOcclusionColor.ref.value, props.postprocessing.occlusion.params.color));
}
@@ -370,7 +377,7 @@ export class IlluminationPass {
// background
const _toDrawingBuffer = toDrawingBuffer && !AntialiasingPass.isEnabled(props.postprocessing) && props.postprocessing.dof.name === 'off';
const _toDrawingBuffer = toDrawingBuffer && !antialiasingEnabled && !dofEnabled;
if (_toDrawingBuffer) {
this.webgl.bindDrawingBuffer();
} else {
@@ -384,7 +391,7 @@ export class IlluminationPass {
// compose
ValueCell.updateIfChanged(this.composeRenderable.values.uTransparentBackground, props.transparentBackground || this.drawPass.postprocessing.background.isEnabled(props.postprocessing.background));
ValueCell.updateIfChanged(this.composeRenderable.values.uTransparentBackground, props.transparentBackground || this.drawPass.postprocessing.background.isEnabled(props.postprocessing));
if (this.composeRenderable.values.dDenoise.ref.value !== props.illumination.denoise) {
ValueCell.update(this.composeRenderable.values.dDenoise, props.illumination.denoise);
needsUpdateCompose = true;
@@ -421,8 +428,8 @@ export class IlluminationPass {
let targetIsDrawingbuffer = false;
let swapTarget = this.outputTarget;
if (AntialiasingPass.isEnabled(props.postprocessing)) {
const _toDrawingBuffer = toDrawingBuffer && props.postprocessing.dof.name === 'off';
if (antialiasingEnabled) {
const _toDrawingBuffer = toDrawingBuffer && !dofEnabled;
this.drawPass.antialiasing.render(camera, this.tracing.composeTarget.texture, _toDrawingBuffer ? true : this.outputTarget, props.postprocessing);
if (_toDrawingBuffer) {
@@ -433,13 +440,13 @@ export class IlluminationPass {
}
}
if (props.postprocessing.bloom.name === 'on') {
const _toDrawingBuffer = (toDrawingBuffer && props.postprocessing.dof.name === 'off') || targetIsDrawingbuffer;
if (bloomEnabled && props.postprocessing.bloom.name === 'on') {
const _toDrawingBuffer = (toDrawingBuffer && !dofEnabled) || targetIsDrawingbuffer;
this.drawPass.bloom.update(this.tracing.colorTextureOpaque, this.tracing.normalTextureOpaque, this.drawPass.depthTextureOpaque, props.postprocessing.bloom.params);
this.drawPass.bloom.render(camera.viewport, _toDrawingBuffer ? undefined : this._colorTarget);
}
if (props.postprocessing.dof.name === 'on') {
if (dofEnabled && props.postprocessing.dof.name === 'on') {
const _toDrawingBuffer = toDrawingBuffer || targetIsDrawingbuffer;
this.drawPass.dof.update(camera, this._colorTarget.texture, this.drawPass.depthTextureOpaque, this.drawPass.depthTargetTransparent.texture, props.postprocessing.dof.params, scene.boundingSphereVisible);
this.drawPass.dof.render(camera.viewport, _toDrawingBuffer ? undefined : swapTarget);

View File

@@ -36,7 +36,7 @@ export type OutlineProps = PD.Values<typeof OutlineParams>
export class OutlinePass {
static isEnabled(props: PostprocessingProps) {
return props.outline.name !== 'off';
return props.enabled && props.outline.name !== 'off';
}
readonly target: RenderTarget;

View File

@@ -20,7 +20,7 @@ export class Passes {
constructor(private webgl: WebGLContext, assetManager: AssetManager, attribs: Partial<{ pickScale: number, transparency: 'wboit' | 'dpoit' | 'blended' }> = {}) {
const drs = this.webgl.getDrawingBufferSize();
this.draw = new DrawPass(webgl, assetManager, drs.width, drs.height, attribs.transparency || 'blended');
this.pick = new PickPass(webgl, this.draw, attribs.pickScale || 0.25);
this.pick = new PickPass(webgl, drs.width, drs.height, attribs.pickScale || 0.25);
this.multiSample = new MultiSamplePass(webgl, this.draw);
this.illumination = new IlluminationPass(webgl, this.draw);
}
@@ -39,7 +39,7 @@ export class Passes {
const width = Math.max(drs.width, 2);
const height = Math.max(drs.height, 2);
this.draw.setSize(width, height);
this.pick.syncSize();
this.pick.setSize(width, height);
this.multiSample.syncSize();
this.illumination.setSize(width, height);
}

View File

@@ -7,6 +7,7 @@
import { PickingId } from '../../mol-geo/geometry/picking';
import { PickType, Renderer } from '../../mol-gl/renderer';
import { Scene } from '../../mol-gl/scene';
import { PixelPackBuffer } from '../../mol-gl/webgl/buffer';
import { isWebGL2 } from '../../mol-gl/webgl/compat';
import { WebGLContext } from '../../mol-gl/webgl/context';
import { Framebuffer } from '../../mol-gl/webgl/framebuffer';
@@ -14,20 +15,29 @@ import { RenderTarget } from '../../mol-gl/webgl/render-target';
import { Renderbuffer } from '../../mol-gl/webgl/renderbuffer';
import { Texture } from '../../mol-gl/webgl/texture';
import { Vec3 } from '../../mol-math/linear-algebra';
import { spiral2d } from '../../mol-math/misc';
import { isTimingMode } from '../../mol-util/debug';
import { unpackRGBToInt, unpackRGBAToDepth } from '../../mol-util/number-packing';
import { Camera, ICamera } from '../camera';
import { StereoCamera } from '../camera/stereo';
import { cameraUnproject } from '../camera/util';
import { isDebugMode, isTimingMode } from '../../mol-util/debug';
import { now } from '../../mol-util/now';
import { unpackRGBAToDepth, unpackRGBToInt } from '../../mol-util/number-packing';
import { ICamera } from '../camera';
import { Viewport } from '../camera/util';
import { Helper } from '../helper/helper';
import { DrawPass } from './draw';
const NullId = Math.pow(2, 24) - 2;
export type PickData = { id: PickingId, position: Vec3 }
export type AsyncPickData = {
tryGet: () => 'pending' | PickData | undefined,
}
export const DefaultPickOptions = {
pickPadding: 1,
maxAsyncReadLag: 5,
};
export type PickOptions = typeof DefaultPickOptions
//
export class PickPass {
private readonly objectPickTarget: RenderTarget;
private readonly instancePickTarget: RenderTarget;
@@ -51,10 +61,10 @@ export class PickPass {
private pickWidth: number;
private pickHeight: number;
constructor(private webgl: WebGLContext, private drawPass: DrawPass, private pickScale: number) {
constructor(private webgl: WebGLContext, private width: number, private height: number, private pickScale: number) {
const pickRatio = pickScale / webgl.pixelRatio;
this.pickWidth = Math.ceil(drawPass.colorTarget.getWidth() * pickRatio);
this.pickHeight = Math.ceil(drawPass.colorTarget.getHeight() * pickRatio);
this.pickWidth = Math.ceil(width * pickRatio);
this.pickHeight = Math.ceil(height * pickRatio);
const { resources, extensions: { drawBuffers }, gl } = webgl;
@@ -109,13 +119,36 @@ export class PickPass {
}
}
dispose() {
if (this.webgl.extensions.drawBuffers) {
this.framebuffer.destroy();
this.objectPickTexture.destroy();
this.instancePickTexture.destroy();
this.groupPickTexture.destroy();
this.depthPickTexture.destroy();
this.objectPickFramebuffer.destroy();
this.instancePickFramebuffer.destroy();
this.groupPickFramebuffer.destroy();
this.depthPickFramebuffer.destroy();
this.depthRenderbuffer.destroy();
} else {
this.objectPickTarget.destroy();
this.instancePickTarget.destroy();
this.groupPickTarget.destroy();
this.depthPickTarget.destroy();
}
}
get pickRatio() {
return this.pickScale / this.webgl.pixelRatio;
}
setPickScale(pickScale: number) {
this.pickScale = pickScale;
this.syncSize();
this.setSize(this.width, this.height);
}
bindObject() {
@@ -151,13 +184,16 @@ export class PickPass {
}
get drawingBufferHeight() {
return this.drawPass.colorTarget.getHeight();
return this.height;
}
syncSize() {
setSize(width: number, height: number) {
this.width = width;
this.height = height;
const pickRatio = this.pickScale / this.webgl.pixelRatio;
const pickWidth = Math.ceil(this.drawPass.colorTarget.getWidth() * pickRatio);
const pickHeight = Math.ceil(this.drawPass.colorTarget.getHeight() * pickRatio);
const pickWidth = Math.ceil(this.width * pickRatio);
const pickHeight = Math.ceil(this.height * pickRatio);
if (pickWidth !== this.pickWidth || pickHeight !== this.pickHeight) {
this.pickWidth = pickWidth;
@@ -225,6 +261,7 @@ export class PickPass {
if (this.webgl.extensions.drawBuffers) {
this.framebuffer.bind();
this.renderVariant(renderer, camera, scene, helper, 'pick', PickType.None);
// printTextureImage(readTexture(this.webgl, this.groupPickTexture, new Uint8Array(this.pickWidth * this.pickHeight * 4)), { scale: 16, id: 'group', pixelated: true, useCanvas: true, flipY: true });
} else {
this.objectPickTarget.bind();
this.renderVariant(renderer, camera, scene, helper, 'pick', PickType.Object);
@@ -234,7 +271,7 @@ export class PickPass {
this.groupPickTarget.bind();
this.renderVariant(renderer, camera, scene, helper, 'pick', PickType.Group);
// printTexture(this.webgl, this.groupPickTarget.texture, { id: 'group' })
// printTextureImage(readTexture(this.webgl, this.groupPickTarget.texture, new Uint8Array(this.pickWidth * this.pickHeight * 4)), { scale: 16, id: 'group', pixelated: true, useCanvas: true, flipY: true });
this.depthPickTarget.bind();
this.renderVariant(renderer, camera, scene, helper, 'depth', PickType.None);
@@ -242,200 +279,220 @@ export class PickPass {
}
}
export class PickHelper {
dirty = true;
let AsyncPickingWarningShown = false;
private objectBuffer: Uint8Array;
private instanceBuffer: Uint8Array;
private groupBuffer: Uint8Array;
private depthBuffer: Uint8Array;
export function checkAsyncPickingSupport(webgl: WebGLContext): boolean {
if (webgl.isWebGL2) return true;
private viewport = Viewport();
if (isDebugMode && !AsyncPickingWarningShown) {
console.log('WebGL2 required for async picking. Falling back to synchronous picking.');
AsyncPickingWarningShown = true;
}
return false;
}
private pickRatio: number;
private pickX: number;
private pickY: number;
private pickWidth: number;
private pickHeight: number;
private halfPickWidth: number;
export enum AsyncPickStatus { Pending, Resolved, Failed };
private spiral: [number, number][];
export class PickBuffers {
private object: Uint8Array;
private instance: Uint8Array;
private group: Uint8Array;
private depth: Uint8Array;
private setupBuffers() {
const bufferSize = this.pickWidth * this.pickHeight * 4;
if (!this.objectBuffer || this.objectBuffer.length !== bufferSize) {
this.objectBuffer = new Uint8Array(bufferSize);
this.instanceBuffer = new Uint8Array(bufferSize);
this.groupBuffer = new Uint8Array(bufferSize);
this.depthBuffer = new Uint8Array(bufferSize);
private objectBuffer: PixelPackBuffer;
private instanceBuffer: PixelPackBuffer;
private groupBuffer: PixelPackBuffer;
private depthBuffer: PixelPackBuffer;
private viewport = Viewport.create(0, 0, 0, 0);
private setup() {
const size = this.viewport.width * this.viewport.height * 4;
if (!this.object || this.object.length !== size) {
this.object = new Uint8Array(size);
this.instance = new Uint8Array(size);
this.group = new Uint8Array(size);
this.depth = new Uint8Array(size);
}
}
setViewport(x: number, y: number, width: number, height: number) {
Viewport.set(this.viewport, x, y, width, height);
this.update();
this.setup();
}
setPickPadding(pickPadding: number) {
if (this.pickPadding !== pickPadding) {
this.pickPadding = pickPadding;
this.update();
}
}
private update() {
read() {
if (isTimingMode) this.webgl.timer.mark('PickBuffers.read');
const { x, y, width, height } = this.viewport;
this.pickRatio = this.pickPass.pickRatio;
this.pickX = Math.ceil(x * this.pickRatio);
this.pickY = Math.ceil(y * this.pickRatio);
const pickWidth = Math.floor(width * this.pickRatio);
const pickHeight = Math.floor(height * this.pickRatio);
if (pickWidth !== this.pickWidth || pickHeight !== this.pickHeight) {
this.pickWidth = pickWidth;
this.pickHeight = pickHeight;
this.halfPickWidth = Math.floor(this.pickWidth / 2);
this.setupBuffers();
}
this.spiral = spiral2d(Math.ceil(this.pickRatio * this.pickPadding));
this.dirty = true;
}
private syncBuffers() {
if (isTimingMode) this.webgl.timer.mark('PickHelper.syncBuffers');
const { pickX, pickY, pickWidth, pickHeight } = this;
this.pickPass.bindObject();
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.objectBuffer);
this.webgl.readPixels(x, y, width, height, this.object);
this.pickPass.bindInstance();
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.instanceBuffer);
this.webgl.readPixels(x, y, width, height, this.instance);
this.pickPass.bindGroup();
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.groupBuffer);
this.webgl.readPixels(x, y, width, height, this.group);
this.pickPass.bindDepth();
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.depthBuffer);
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.syncBuffers');
this.webgl.readPixels(x, y, width, height, this.depth);
this.ready = true;
if (isTimingMode) this.webgl.timer.markEnd('PickBuffers.read');
}
private getBufferIdx(x: number, y: number): number {
return (y * this.pickWidth + x) * 4;
private fenceSync: WebGLSync | null = null;
private fenceTimestamp: number = 0;
private ready = false;
private lag = 0;
asyncRead() {
const { gl } = this.webgl;
if (!isWebGL2(gl)) return;
if (isTimingMode) this.webgl.timer.mark('PickBuffers.asyncRead');
if (this.fenceSync !== null) {
gl.deleteSync(this.fenceSync);
}
const { x, y, width, height } = this.viewport;
this.pickPass.bindObject();
this.objectBuffer.read(x, y, width, height);
this.pickPass.bindInstance();
this.instanceBuffer.read(x, y, width, height);
this.pickPass.bindGroup();
this.groupBuffer.read(x, y, width, height);
this.pickPass.bindDepth();
this.depthBuffer.read(x, y, width, height);
this.fenceTimestamp = now();
this.fenceSync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
// gl.flush();
this.ready = false;
if (isTimingMode) this.webgl.timer.markEnd('PickBuffers.asyncRead');
}
private getDepth(x: number, y: number): number {
const idx = this.getBufferIdx(x, y);
const b = this.depthBuffer;
check(): AsyncPickStatus {
if (this.ready) return AsyncPickStatus.Resolved;
if (this.fenceSync === null) return AsyncPickStatus.Failed;
const { gl } = this.webgl;
if (!isWebGL2(gl)) return AsyncPickStatus.Failed;
const res = gl.clientWaitSync(this.fenceSync, 0, 0);
if (res === gl.WAIT_FAILED || this.lag >= this.maxAsyncReadLag) {
// console.log(`failed to get buffer data after ${this.lag + 1} checks`);
if (res !== gl.WAIT_FAILED && now() - this.fenceTimestamp < 1000 / 60) {
this.lag += 1;
return AsyncPickStatus.Pending;
}
gl.deleteSync(this.fenceSync);
this.fenceSync = null;
this.lag = 0;
this.ready = false;
return AsyncPickStatus.Failed;
} else if (res === gl.TIMEOUT_EXPIRED) {
this.lag += 1;
// console.log(`waiting for buffer data for ${this.lag} checks`);
return AsyncPickStatus.Pending;
} else {
this.objectBuffer.getSubData(this.object);
this.instanceBuffer.getSubData(this.instance);
this.groupBuffer.getSubData(this.group);
this.depthBuffer.getSubData(this.depth);
// console.log(`got buffer data after ${this.lag + 1} checks`);
gl.deleteSync(this.fenceSync);
this.fenceSync = null;
this.lag = 0;
this.ready = true;
return AsyncPickStatus.Resolved;
}
}
private getIdx(x: number, y: number): number {
return (y * this.viewport.width + x) * 4;
}
getDepth(x: number, y: number): number {
if (!this.ready) return -1;
const idx = this.getIdx(x, y);
const b = this.depth;
return unpackRGBAToDepth(b[idx], b[idx + 1], b[idx + 2], b[idx + 3]);
}
private getId(x: number, y: number, buffer: Uint8Array) {
const idx = this.getBufferIdx(x, y);
if (!this.ready) return -1;
const idx = this.getIdx(x, y);
return unpackRGBToInt(buffer[idx], buffer[idx + 1], buffer[idx + 2]);
}
private render(camera: Camera | StereoCamera) {
if (isTimingMode) this.webgl.timer.mark('PickHelper.render', { captureStats: true });
const { pickX, pickY, pickWidth, pickHeight, halfPickWidth } = this;
const { renderer, scene, helper } = this;
renderer.setTransparentBackground(false);
renderer.setDrawingBufferSize(pickWidth, pickHeight);
renderer.setPixelRatio(this.pickRatio);
if (StereoCamera.is(camera)) {
renderer.setViewport(pickX, pickY, halfPickWidth, pickHeight);
this.pickPass.render(renderer, camera.left, scene, helper);
renderer.setViewport(pickX + halfPickWidth, pickY, pickWidth - halfPickWidth, pickHeight);
this.pickPass.render(renderer, camera.right, scene, helper);
} else {
renderer.setViewport(pickX, pickY, pickWidth, pickHeight);
this.pickPass.render(renderer, camera, scene, helper);
}
this.dirty = false;
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.render');
getObjectId(x: number, y: number) {
return this.getId(x, y, this.object);
}
private identifyInternal(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
if (this.pickRatio !== this.pickPass.pickRatio) {
this.update();
}
getInstanceId(x: number, y: number) {
return this.getId(x, y, this.instance);
}
const { webgl, pickRatio } = this;
if (webgl.isContextLost) return;
getGroupId(x: number, y: number) {
return this.getId(x, y, this.group);
}
x *= webgl.pixelRatio;
y *= webgl.pixelRatio;
y = this.pickPass.drawingBufferHeight - y; // flip y
const { viewport } = this;
// check if within viewport
if (x < viewport.x ||
y < viewport.y ||
x > viewport.x + viewport.width ||
y > viewport.y + viewport.height
) return;
if (this.dirty) {
if (isTimingMode) this.webgl.timer.mark('PickHelper.identify');
this.render(camera);
this.syncBuffers();
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.identify');
}
const xv = x - viewport.x;
const yv = y - viewport.y;
const xp = Math.floor(xv * pickRatio);
const yp = Math.floor(yv * pickRatio);
const objectId = this.getId(xp, yp, this.objectBuffer);
getPickingId(x: number, y: number): PickingId | undefined {
const objectId = this.getObjectId(x, y);
// console.log('objectId', objectId);
if (objectId === -1 || objectId === NullId) return;
const instanceId = this.getId(xp, yp, this.instanceBuffer);
const instanceId = this.getInstanceId(x, y);
// console.log('instanceId', instanceId);
if (instanceId === -1 || instanceId === NullId) return;
const groupId = this.getId(xp, yp, this.groupBuffer);
const groupId = this.getGroupId(x, y);
// console.log('groupId', groupId);
if (groupId === -1 || groupId === NullId) return;
const z = this.getDepth(xp, yp);
// console.log('z', z);
const position = Vec3.create(x, y, z);
if (StereoCamera.is(camera)) {
const halfWidth = Math.floor(viewport.width / 2);
if (x > viewport.x + halfWidth) {
position[0] = viewport.x + (xv - halfWidth) * 2;
cameraUnproject(position, position, viewport, camera.right.inverseProjectionView);
} else {
position[0] = viewport.x + xv * 2;
cameraUnproject(position, position, viewport, camera.left.inverseProjectionView);
}
} else {
cameraUnproject(position, position, viewport, camera.inverseProjectionView);
}
// console.log({ id: { objectId, instanceId, groupId }, position });
return { id: { objectId, instanceId, groupId }, position };
return { objectId, instanceId, groupId };
}
identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
for (const d of this.spiral) {
const pickData = this.identifyInternal(x + d[0], y + d[1], camera);
if (pickData) return pickData;
reset() {
this.fenceSync = null;
this.ready = false;
this.lag = 0;
this.fenceTimestamp = 0;
}
dispose() {
const { gl } = this.webgl;
if (!isWebGL2(gl)) return;
this.objectBuffer.destroy();
this.instanceBuffer.destroy();
this.groupBuffer.destroy();
this.depthBuffer.destroy();
if (this.fenceSync !== null) {
gl.deleteSync(this.fenceSync);
this.fenceSync = null;
}
}
constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport, private pickPadding = 1) {
this.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
constructor(private webgl: WebGLContext, private pickPass: PickPass, public maxAsyncReadLag = 5) {
if (webgl.isWebGL2) {
this.objectBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
this.instanceBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
this.groupBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
this.depthBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
}
this.setup();
}
}
}

View File

@@ -120,59 +120,60 @@ function getPostprocessingRenderable(ctx: WebGLContext, colorTexture: Texture, t
}
export const PostprocessingParams = {
enabled: PD.Boolean(true),
occlusion: PD.MappedStatic('on', {
on: PD.Group(SsaoParams),
off: PD.Group({})
}, { cycle: true, description: 'Darken occluded crevices with the ambient occlusion effect' }),
}, { cycle: true, description: 'Darken occluded crevices with the ambient occlusion effect', hideIf: p => p.enabled === false }),
shadow: PD.MappedStatic('off', {
on: PD.Group(ShadowParams),
off: PD.Group({})
}, { cycle: true, description: 'Simplistic shadows' }),
}, { cycle: true, description: 'Simplistic shadows', hideIf: p => p.enabled === false }),
outline: PD.MappedStatic('off', {
on: PD.Group(OutlineParams),
off: PD.Group({})
}, { cycle: true, description: 'Draw outline around 3D objects' }),
}, { cycle: true, description: 'Draw outline around 3D objects', hideIf: p => p.enabled === false }),
dof: PD.MappedStatic('off', {
on: PD.Group(DofParams),
off: PD.Group({})
}, { cycle: true, description: 'DOF' }),
}, { cycle: true, description: 'DOF', hideIf: p => p.enabled === false }),
antialiasing: PD.MappedStatic('smaa', {
fxaa: PD.Group(FxaaParams),
smaa: PD.Group(SmaaParams),
off: PD.Group({})
}, { options: [['fxaa', 'FXAA'], ['smaa', 'SMAA'], ['off', 'Off']], description: 'Smooth pixel edges' }),
}, { options: [['fxaa', 'FXAA'], ['smaa', 'SMAA'], ['off', 'Off']], description: 'Smooth pixel edges', hideIf: p => p.enabled === false }),
sharpening: PD.MappedStatic('off', {
on: PD.Group(CasParams),
off: PD.Group({})
}, { cycle: true, description: 'Contrast Adaptive Sharpening' }),
background: PD.Group(BackgroundParams, { isFlat: true }),
}, { cycle: true, description: 'Contrast Adaptive Sharpening', hideIf: p => p.enabled === false }),
background: PD.Group(BackgroundParams, { isFlat: true, hideIf: p => p.enabled === false }),
bloom: PD.MappedStatic('on', {
on: PD.Group(BloomParams),
off: PD.Group({})
}, { cycle: true, description: 'Bloom' }),
}, { cycle: true, description: 'Bloom', hideIf: p => p.enabled === false }),
};
export type PostprocessingProps = PD.Values<typeof PostprocessingParams>
export class PostprocessingPass {
static isEnabled(props: PostprocessingProps) {
return SsaoPass.isEnabled(props) || ShadowPass.isEnabled(props) || OutlinePass.isEnabled(props) || props.background.variant.name !== 'off';
return props.enabled && (SsaoPass.isEnabled(props) || ShadowPass.isEnabled(props) || OutlinePass.isEnabled(props) || props.background.variant.name !== 'off');
}
static isTransparentDepthRequired(scene: Scene, props: PostprocessingProps) {
return DofPass.isEnabled(props) || OutlinePass.isEnabled(props) && PostprocessingPass.isTransparentOutlineEnabled(props) || SsaoPass.isEnabled(props) && PostprocessingPass.isTransparentSsaoEnabled(scene, props);
return props.enabled && (DofPass.isEnabled(props) || OutlinePass.isEnabled(props) && PostprocessingPass.isTransparentOutlineEnabled(props) || SsaoPass.isEnabled(props) && PostprocessingPass.isTransparentSsaoEnabled(scene, props));
}
static isTransparentOutlineEnabled(props: PostprocessingProps) {
return OutlinePass.isEnabled(props) && ((props.outline.params as OutlineProps).includeTransparent ?? true);
return props.enabled && OutlinePass.isEnabled(props) && ((props.outline.params as OutlineProps).includeTransparent ?? true);
}
static isTransparentSsaoEnabled(scene: Scene, props: PostprocessingProps) {
return SsaoPass.isEnabled(props) && SsaoPass.isTransparentEnabled(scene, props.occlusion.params as SsaoProps);
return props.enabled && SsaoPass.isEnabled(props) && SsaoPass.isTransparentEnabled(scene, props.occlusion.params as SsaoProps);
}
static isSsaoEnabled(props: PostprocessingProps) {
return SsaoPass.isEnabled(props);
return props.enabled && SsaoPass.isEnabled(props);
}
readonly target: RenderTarget;
@@ -354,7 +355,7 @@ export class PostprocessingPass {
export class AntialiasingPass {
static isEnabled(props: PostprocessingProps) {
return props.antialiasing.name !== 'off';
return props.enabled && (props.antialiasing.name !== 'off' || props.sharpening.name !== 'off');
}
readonly target: RenderTarget;

View File

@@ -1,5 +1,5 @@
/**
* 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 Alexander Rose <alexander.rose@weirdbyte.de>
* @author Áron Samuel Kovács <aron.kovacs@mail.muni.cz>
@@ -35,7 +35,7 @@ export type ShadowProps = PD.Values<typeof ShadowParams>
export class ShadowPass {
static isEnabled(props: PostprocessingProps) {
return props.shadow.name !== 'off';
return props.enabled && props.shadow.name !== 'off';
}
readonly target: RenderTarget;
@@ -83,8 +83,8 @@ export class ShadowPass {
needsUpdateShadows = true;
}
ValueCell.updateIfChanged(this.renderable.values.uMaxDistance, props.maxDistance);
ValueCell.updateIfChanged(this.renderable.values.uTolerance, props.tolerance);
ValueCell.updateIfChanged(this.renderable.values.uMaxDistance, props.maxDistance * camera.state.scale);
ValueCell.updateIfChanged(this.renderable.values.uTolerance, props.tolerance * camera.state.scale);
if (this.renderable.values.dSteps.ref.value !== props.steps) {
ValueCell.update(this.renderable.values.dSteps, props.steps);
needsUpdateShadows = true;

View File

@@ -63,7 +63,7 @@ type Levels = {
bias: number[]
}
function getLevels(props: { radius: number, bias: number }[], levels?: Levels): Levels {
function getLevels(props: { radius: number, bias: number }[], scale: number, levels?: Levels): Levels {
const count = props.length;
const { radius, bias } = levels || {
radius: (new Array(count * 3)).fill(0),
@@ -72,7 +72,7 @@ function getLevels(props: { radius: number, bias: number }[], levels?: Levels):
props = props.slice().sort((a, b) => a.radius - b.radius);
for (let i = 0; i < count; ++i) {
const p = props[i];
radius[i] = Math.pow(2, p.radius);
radius[i] = Math.pow(2, p.radius) * scale;
bias[i] = p.bias;
}
return { count, radius, bias };
@@ -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;
@@ -306,8 +305,8 @@ export class SsaoPass {
ValueCell.update(this.blurFirstPassRenderable.values.uInvProjection, invProjection);
ValueCell.update(this.blurSecondPassRenderable.values.uInvProjection, invProjection);
ValueCell.update(this.blurFirstPassRenderable.values.uBlurDepthBias, props.blurDepthBias);
ValueCell.update(this.blurSecondPassRenderable.values.uBlurDepthBias, props.blurDepthBias);
ValueCell.update(this.blurFirstPassRenderable.values.uBlurDepthBias, props.blurDepthBias * camera.state.scale);
ValueCell.update(this.blurSecondPassRenderable.values.uBlurDepthBias, props.blurDepthBias * camera.state.scale);
if (this.blurFirstPassRenderable.values.dOrthographic.ref.value !== orthographic) {
needsUpdateSsaoBlur = true;
@@ -349,7 +348,7 @@ export class SsaoPass {
needsUpdateSsao = true;
this.levels = mp.levels;
const levels = getLevels(mp.levels);
const levels = getLevels(mp.levels, camera.state.scale);
ValueCell.updateIfChanged(this.renderable.values.dLevels, levels.count);
ValueCell.update(this.renderable.values.uLevelRadius, levels.radius);
@@ -358,7 +357,7 @@ export class SsaoPass {
ValueCell.updateIfChanged(this.renderable.values.uNearThreshold, mp.nearThreshold);
ValueCell.updateIfChanged(this.renderable.values.uFarThreshold, mp.farThreshold);
} else {
ValueCell.updateIfChanged(this.renderable.values.uRadius, Math.pow(2, props.radius));
ValueCell.updateIfChanged(this.renderable.values.uRadius, Math.pow(2, props.radius) * camera.state.scale);
}
ValueCell.updateIfChanged(this.renderable.values.uBias, props.bias);

View File

@@ -1,8 +1,9 @@
/**
* Copyright (c) 2017-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Adam Midlik <midlik@gmail.com>
*/
// from http://burtleburtle.net/bob/hash/integer.html
@@ -89,4 +90,456 @@ export function hashFnv32a(array: ArrayLike<number>) {
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}
return hval >>> 0;
}
/**
* 256 bit FNV-1a hash, returns 8 32-bit words
* Based on the FNV-1a algorithm extended to 256 bits
*/
export function hashFnv256a(array: ArrayLike<number>, out: Uint32Array) {
out.set(Fnv256Base);
for (let i = 0, il = array.length; i < il; ++i) {
// XOR with input byte
out[0] ^= array[i] & 0xff;
// Multiply by FNV prime (256-bit multiplication)
multiplyBy256BitPrime(out);
}
return out;
}
/**
* 256-bit object hash function using FNV-1a
*/
export function hashFnv256o(obj: any): string {
return _Hasher256.hash(obj);
}
class ObjectHasher256 {
private hashTarget: Uint32Array = new Uint32Array(8);
private numberBytes = new Uint8Array(8);
private numberView = new DataView(this.numberBytes.buffer);
hash(obj: any): string {
this.hashTarget.set(Fnv256Base);
this.hashValue(obj, 0);
return hashFnv256aToHex(this.hashTarget);
}
private hashValue(value: any, depth: number): void {
if (depth > 50) return;
const type = typeof value;
this.addByte(type.charCodeAt(0));
switch (type) {
case 'string':
this.addString(value);
break;
case 'number':
this.addNumber(value);
break;
case 'boolean':
this.addByte(value ? 1 : 0);
break;
case 'object':
if (value === null) {
this.addByte(0);
} else if (Array.isArray(value)) {
this.addArray(value, depth);
} else {
this.addObject(value, depth);
}
break;
case 'undefined':
this.addByte(255);
break;
}
}
private addByte(byte: number): void {
// XOR with input byte
this.hashTarget[0] ^= byte & 0xff;
// Multiply by FNV prime (256-bit multiplication)
multiplyBy256BitPrime(this.hashTarget);
}
private addString(str: string): void {
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 128) {
this.addByte(code);
} else if (code < 2048) {
this.addByte(0xc0 | (code >> 6));
this.addByte(0x80 | (code & 0x3f));
} else {
this.addByte(0xe0 | (code >> 12));
this.addByte(0x80 | ((code >> 6) & 0x3f));
this.addByte(0x80 | (code & 0x3f));
}
}
}
private addNumber(num: number): void {
if (Number.isNaN(num)) {
this.addByte(0x7f); this.addByte(0xc0); this.addByte(0x00); this.addByte(0x00);
this.addByte(0x00); this.addByte(0x00); this.addByte(0x00); this.addByte(0x00);
} else if (!Number.isFinite(num)) {
if (num > 0) {
this.addByte(0x7f); this.addByte(0x80); this.addByte(0x00); this.addByte(0x00);
} else {
this.addByte(0xff); this.addByte(0x80); this.addByte(0x00); this.addByte(0x00);
}
this.addByte(0x00); this.addByte(0x00); this.addByte(0x00); this.addByte(0x00);
} else {
this.numberView.setFloat64(0, num, false);
for (let i = 0; i < 8; i++) {
this.addByte(this.numberBytes[i]);
}
}
}
private addArray(arr: any[], depth: number): void {
this.addNumber(arr.length);
for (let i = 0; i < arr.length; i++) {
this.addNumber(i);
this.hashValue(arr[i], depth + 1);
}
}
private addObject(obj: any, depth: number): void {
const keys = Object.keys(obj).sort();
this.addNumber(keys.length);
for (const key of keys) {
this.addString(key);
this.hashValue(obj[key], depth + 1);
}
}
}
const _Hasher256 = new ObjectHasher256();
const Fnv256Base = new Uint32Array([
0x6c62272e, 0x07bb0142, 0x62b82175, 0x6295c58d,
0x16d67530, 0xdd7121e3, 0xb3174000, 0x00000100
]);
const MultTmp1 = new Uint32Array(8);
const MultTmp2 = new Uint32Array(8);
/**
* Helper function to multiply 256-bit number by FNV prime
*/
function multiplyBy256BitPrime(hash: Uint32Array): void {
// Since FNV 256-bit prime is 2^88 + 2^8 + 0x3b, we can optimize:
// hash * prime = hash * (2^88 + 2^8 + 0x3b) = (hash << 88) + (hash << 8) + hash * 0x3b
// hash << 88 (shift left by 88 bits = 2 full 32-bit words + 24 bits)
MultTmp1[0] = 0;
MultTmp1[1] = 0;
MultTmp1[2] = hash[0] << 24;
MultTmp1[3] = (hash[0] >>> 8) | (hash[1] << 24);
MultTmp1[4] = (hash[1] >>> 8) | (hash[2] << 24);
MultTmp1[5] = (hash[2] >>> 8) | (hash[3] << 24);
MultTmp1[6] = (hash[3] >>> 8) | (hash[4] << 24);
MultTmp1[7] = (hash[4] >>> 8) | (hash[5] << 24);
// hash << 8
MultTmp2[0] = hash[0] << 8;
MultTmp2[1] = (hash[0] >>> 24) | (hash[1] << 8);
MultTmp2[2] = (hash[1] >>> 24) | (hash[2] << 8);
MultTmp2[3] = (hash[2] >>> 24) | (hash[3] << 8);
MultTmp2[4] = (hash[3] >>> 24) | (hash[4] << 8);
MultTmp2[5] = (hash[4] >>> 24) | (hash[5] << 8);
MultTmp2[6] = (hash[5] >>> 24) | (hash[6] << 8);
MultTmp2[7] = (hash[6] >>> 24) | (hash[7] << 8);
// hash * 0x3b (simple multiplication by small constant)
let carry = 0;
for (let i = 0; i < 8; i++) {
const product = hash[i] * 0x3b + carry;
hash[i] = product >>> 0;
carry = Math.floor(product / 0x100000000);
}
// Add all three components: (hash << 88) + (hash << 8) + hash * 0x3b
carry = 0;
for (let i = 0; i < 8; i++) {
const sum = hash[i] + MultTmp1[i] + MultTmp2[i] + carry;
hash[i] = sum >>> 0;
carry = sum >= 0x100000000 ? 1 : 0;
}
}
const _8digit_padding = [
'00000000',
'0000000',
'000000',
'00000',
'0000',
'000',
'00',
'0'
];
function padHexNumber(num: number): string {
const base = num.toString(16);
if (base.length >= 8) return base; // No padding needed
return _8digit_padding[base.length] + base;
}
/**
* Convert 256-bit hash to hex string
*/
function hashFnv256aToHex(hash: Uint32Array): string {
let result = '';
for (let i = 7; i >= 0; i--) {
result += padHexNumber(hash[i]);
}
return result;
}
/**
* 32-bit Murmur hash
*/
export function hashMurmur32o(obj: any, seed: number = 42): number {
const jsonString = JSON.stringify(obj);
return murmurHash3_32(jsonString, seed);
}
/**
* 128-bit Murmur hash
*/
export function hashMurmur128o(obj: any, seed: number = 42): string {
const jsonString = JSON.stringify(obj);
return murmurHash3_128(jsonString, seed);
}
/**
* MurmurHash3 32-bit implementation
* @param key - The input string to hash
* @param seed - The seed value (default: 0)
* @returns The 32-bit hash as a number
*/
export function murmurHash3_32(key: string, seed: number): number {
let h = seed >>> 0;
const remainder = key.length % 4;
const bytes = key.length - remainder;
for (let i = 0; i < bytes; i += 4) {
let k = (key.charCodeAt(i) & 0xff) |
((key.charCodeAt(i + 1) & 0xff) << 8) |
((key.charCodeAt(i + 2) & 0xff) << 16) |
((key.charCodeAt(i + 3) & 0xff) << 24);
k = Math.imul(k, 0xcc9e2d51);
k = (k << 15) | (k >>> 17);
k = Math.imul(k, 0x1b873593);
h ^= k;
h = (h << 13) | (h >>> 19);
h = Math.imul(h, 5) + 0xe6546b64;
}
let k = 0;
switch (remainder) {
case 3: k ^= (key.charCodeAt(bytes + 2) & 0xff) << 16;
case 2: k ^= (key.charCodeAt(bytes + 1) & 0xff) << 8;
case 1: k ^= (key.charCodeAt(bytes) & 0xff);
k = Math.imul(k, 0xcc9e2d51);
k = (k << 15) | (k >>> 17);
k = Math.imul(k, 0x1b873593);
h ^= k;
}
h ^= key.length;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
/**
* MurmurHash3 128-bit implementation
* @param key - The input data to hash
* @param seed - The seed value (default: 0)
* @returns The 128-bit hash as a hexadecimal string
*/
export function murmurHash3_128_fromBytes(key: Uint8Array, seed: number): string {
// This fakeString approach is much faster than `new TextDecoder('ascii').decode(key)`
const fakeString = {
length: key.length,
charCodeAt(i: number) { return key[i]; },
};
return murmurHash3_128(fakeString as string, seed);
}
/**
* MurmurHash3 128-bit implementation
* @param key - The input string to hash
* @param seed - The seed value (default: 0)
* @returns The 128-bit hash as a hexadecimal string
*/
export function murmurHash3_128(key: string, seed: number): string {
let h1 = seed >>> 0;
let h2 = seed >>> 0;
let h3 = seed >>> 0;
let h4 = seed >>> 0;
const remainder = key.length % 16;
const bytes = key.length - remainder;
for (let i = 0; i < bytes; i += 16) {
let k1 = (key.charCodeAt(i) & 0xff) |
((key.charCodeAt(i + 1) & 0xff) << 8) |
((key.charCodeAt(i + 2) & 0xff) << 16) |
((key.charCodeAt(i + 3) & 0xff) << 24);
let k2 = (key.charCodeAt(i + 4) & 0xff) |
((key.charCodeAt(i + 5) & 0xff) << 8) |
((key.charCodeAt(i + 6) & 0xff) << 16) |
((key.charCodeAt(i + 7) & 0xff) << 24);
let k3 = (key.charCodeAt(i + 8) & 0xff) |
((key.charCodeAt(i + 9) & 0xff) << 8) |
((key.charCodeAt(i + 10) & 0xff) << 16) |
((key.charCodeAt(i + 11) & 0xff) << 24);
let k4 = (key.charCodeAt(i + 12) & 0xff) |
((key.charCodeAt(i + 13) & 0xff) << 8) |
((key.charCodeAt(i + 14) & 0xff) << 16) |
((key.charCodeAt(i + 15) & 0xff) << 24);
k1 = Math.imul(k1, 0x239b961b);
k1 = (k1 << 15) | (k1 >>> 17);
k1 = Math.imul(k1, 0xab0e9789);
h1 ^= k1;
h1 = (h1 << 19) | (h1 >>> 13);
h1 += h2;
h1 = Math.imul(h1, 5) + 0x561ccd1b;
k2 = Math.imul(k2, 0xab0e9789);
k2 = (k2 << 16) | (k2 >>> 16);
k2 = Math.imul(k2, 0x38b34ae5);
h2 ^= k2;
h2 = (h2 << 17) | (h2 >>> 15);
h2 += h3;
h2 = Math.imul(h2, 5) + 0x0bcaa747;
k3 = Math.imul(k3, 0x38b34ae5);
k3 = (k3 << 17) | (k3 >>> 15);
k3 = Math.imul(k3, 0xa1e38b93);
h3 ^= k3;
h3 = (h3 << 15) | (h3 >>> 17);
h3 += h4;
h3 = Math.imul(h3, 5) + 0x96cd1c35;
k4 = Math.imul(k4, 0xa1e38b93);
k4 = (k4 << 13) | (k4 >>> 19);
k4 = Math.imul(k4, 0x239b961b);
h4 ^= k4;
h4 = (h4 << 13) | (h4 >>> 19);
h4 += h1;
h4 = Math.imul(h4, 5) + 0x32ac3b17;
}
let k1 = 0, k2 = 0, k3 = 0, k4 = 0;
switch (remainder) {
case 15: k4 ^= key.charCodeAt(bytes + 14) << 16;
case 14: k4 ^= key.charCodeAt(bytes + 13) << 8;
case 13: k4 ^= key.charCodeAt(bytes + 12);
k4 = Math.imul(k4, 0xa1e38b93);
k4 = (k4 << 13) | (k4 >>> 19);
k4 = Math.imul(k4, 0x239b961b);
h4 ^= k4;
case 12: k3 ^= key.charCodeAt(bytes + 11) << 24;
case 11: k3 ^= key.charCodeAt(bytes + 10) << 16;
case 10: k3 ^= key.charCodeAt(bytes + 9) << 8;
case 9: k3 ^= key.charCodeAt(bytes + 8);
k3 = Math.imul(k3, 0x38b34ae5);
k3 = (k3 << 17) | (k3 >>> 15);
k3 = Math.imul(k3, 0xa1e38b93);
h3 ^= k3;
case 8: k2 ^= key.charCodeAt(bytes + 7) << 24;
case 7: k2 ^= key.charCodeAt(bytes + 6) << 16;
case 6: k2 ^= key.charCodeAt(bytes + 5) << 8;
case 5: k2 ^= key.charCodeAt(bytes + 4);
k2 = Math.imul(k2, 0xab0e9789);
k2 = (k2 << 16) | (k2 >>> 16);
k2 = Math.imul(k2, 0x38b34ae5);
h2 ^= k2;
case 4: k1 ^= key.charCodeAt(bytes + 3) << 24;
case 3: k1 ^= key.charCodeAt(bytes + 2) << 16;
case 2: k1 ^= key.charCodeAt(bytes + 1) << 8;
case 1: k1 ^= key.charCodeAt(bytes);
k1 = Math.imul(k1, 0x239b961b);
k1 = (k1 << 15) | (k1 >>> 17);
k1 = Math.imul(k1, 0xab0e9789);
h1 ^= k1;
}
h1 ^= key.length;
h2 ^= key.length;
h3 ^= key.length;
h4 ^= key.length;
h1 += h2;
h1 += h3;
h1 += h4;
h2 += h1;
h3 += h1;
h4 += h1;
h1 ^= h1 >>> 16;
h1 = Math.imul(h1, 0x85ebca6b);
h1 ^= h1 >>> 13;
h1 = Math.imul(h1, 0xc2b2ae35);
h1 ^= h1 >>> 16;
h2 ^= h2 >>> 16;
h2 = Math.imul(h2, 0x85ebca6b);
h2 ^= h2 >>> 13;
h2 = Math.imul(h2, 0xc2b2ae35);
h2 ^= h2 >>> 16;
h3 ^= h3 >>> 16;
h3 = Math.imul(h3, 0x85ebca6b);
h3 ^= h3 >>> 13;
h3 = Math.imul(h3, 0xc2b2ae35);
h3 ^= h3 >>> 16;
h4 ^= h4 >>> 16;
h4 = Math.imul(h4, 0x85ebca6b);
h4 ^= h4 >>> 13;
h4 = Math.imul(h4, 0xc2b2ae35);
h4 ^= h4 >>> 16;
h1 += h2;
h1 += h3;
h1 += h4;
h2 += h1;
h3 += h1;
h4 += h1;
return (
(h1 >>> 0).toString(16).padStart(8, '0') +
(h2 >>> 0).toString(16).padStart(8, '0') +
(h3 >>> 0).toString(16).padStart(8, '0') +
(h4 >>> 0).toString(16).padStart(8, '0')
);
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 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 Gianluca Tomasello <giagitom@gmail.com>
@@ -12,6 +12,7 @@ import { Cylinder, CylinderProps, DefaultCylinderProps } from '../../../primitiv
import { Prism } from '../../../primitive/prism';
import { polygon } from '../../../primitive/polygon';
import { hashFnv32a } from '../../../../mol-data/util';
import { Ray3D } from '../../../../mol-math/geometry/primitives/ray3d';
const cylinderMap = new Map<number, Primitive>();
const up = Vec3.create(0, 1, 0);
@@ -77,6 +78,11 @@ export function addSimpleCylinder(state: MeshBuilder.State, start: Vec3, end: Ve
MeshBuilder.addPrimitive(state, tmpCylinderMat, getCylinder(props));
}
export function addCylinderFromRay3D(state: MeshBuilder.State, ray: Ray3D, length: number, props: BasicCylinderProps) {
setCylinderMat(tmpCylinderMat, ray.origin, ray.direction, length, false);
MeshBuilder.addPrimitive(state, tmpCylinderMat, getCylinder(props));
}
export function addCylinder(state: MeshBuilder.State, start: Vec3, end: Vec3, lengthScale: number, props: BasicCylinderProps) {
const d = Vec3.distance(start, end) * lengthScale;
Vec3.sub(tmpCylinderDir, end, start);

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -22,10 +22,4 @@ export namespace Object3D {
up: Vec3.create(0, 1, 0),
};
}
const center = Vec3.zero();
export function update(object3d: Object3D) {
Vec3.add(center, object3d.position, object3d.direction);
Mat4.lookAt(object3d.view, object3d.position, center, object3d.up);
}
}

View File

@@ -130,11 +130,14 @@ export const GlobalUniformSchema = {
uInvProjection: UniformSpec('m4'),
uModelViewProjection: UniformSpec('m4'),
uInvModelViewProjection: UniformSpec('m4'),
uHasHeadRotation: UniformSpec('b'),
uInvHeadRotation: UniformSpec('m4'),
uIsOrtho: UniformSpec('f'),
uPixelRatio: UniformSpec('f'),
uViewport: UniformSpec('v4'),
uViewOffset: UniformSpec('v2'),
uModelScale: UniformSpec('f'),
uDrawingBufferSize: UniformSpec('v2'),
uCameraPosition: UniformSpec('v3'),

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>
*/
@@ -50,6 +50,7 @@ const DefaultPrintImageOptions = {
id: 'molstar.debug.image',
normalize: false,
useCanvas: false,
flipY: false,
};
export type PrintImageOptions = typeof DefaultPrintImageOptions
@@ -101,6 +102,7 @@ export function printImageData(imageData: ImageData, options: Partial<PrintImage
tmpContainer.style.right = '0px';
tmpContainer.style.border = 'solid orange';
tmpContainer.style.pointerEvents = 'none';
if (options.flipY) tmpContainer.style.transform = 'scaleY(-1)';
document.body.appendChild(tmpContainer);
}

View File

@@ -152,6 +152,16 @@ function getLight(props: RendererProps['light'], light?: Light): Light {
return { count, direction, color };
}
export function getTransformedLightDirection(light: Light, t: Mat4): Light['direction'] {
const tld = new Array(light.count * 3);
for (let i = 0, il = light.count; i < il; ++i) {
Vec3.fromArray(tmpDir, light.direction, i * 3);
Vec3.transformDirection(tmpDir, tmpDir, t);
Vec3.toArray(tmpDir, tld, i * 3);
}
return tld;
}
namespace Renderer {
const enum Flag {
None = 0,
@@ -184,6 +194,7 @@ namespace Renderer {
['tDepth', emptyDepthTexture]
];
const model = Mat4();
const view = Mat4();
const invView = Mat4();
const modelView = Mat4();
@@ -191,6 +202,7 @@ namespace Renderer {
const invProjection = Mat4();
const modelViewProjection = Mat4();
const invModelViewProjection = Mat4();
const invHeadRotation = Mat4();
const cameraDir = Vec3();
const cameraPosition = Vec3();
@@ -198,6 +210,9 @@ namespace Renderer {
const viewOffset = Vec2();
const frustum = Frustum3D();
let modelScale = 1;
const boundingSphere = Sphere3D();
const ambientColor = Vec3();
Vec3.scale(ambientColor, Color.toArrayNormalized(p.ambientColor, ambientColor, 0), p.ambientIntensity);
@@ -213,9 +228,12 @@ namespace Renderer {
uProjection: ValueCell.create(Mat4()),
uModelViewProjection: ValueCell.create(modelViewProjection),
uInvModelViewProjection: ValueCell.create(invModelViewProjection),
uHasHeadRotation: ValueCell.create(false),
uInvHeadRotation: ValueCell.create(invHeadRotation),
uIsOrtho: ValueCell.create(1),
uViewOffset: ValueCell.create(viewOffset),
uModelScale: ValueCell.create(1),
uPixelRatio: ValueCell.create(ctx.pixelRatio),
uViewport: ValueCell.create(Viewport.toVec4(Vec4(), viewport)),
@@ -274,28 +292,33 @@ namespace Renderer {
return;
}
if (!Frustum3D.intersectsSphere3D(frustum, r.values.boundingSphere.ref.value)) {
Sphere3D.scaleNX(boundingSphere, r.values.boundingSphere.ref.value, modelScale);
if (!Frustum3D.intersectsSphere3D(frustum, boundingSphere)) {
return;
}
const [minDistance, maxDistance] = r.values.uLod.ref.value;
if (minDistance !== 0 || maxDistance !== 0) {
const { center, radius } = r.values.boundingSphere.ref.value;
const { center, radius } = boundingSphere;
const d = Plane3D.distanceToPoint(cameraPlane, center);
if (d + radius < minDistance) return;
if (d - radius > maxDistance) return;
if (d + radius < minDistance * modelScale) return;
if (d - radius > maxDistance * modelScale) return;
}
if (isOccluded !== null && isOccluded(r.values.boundingSphere.ref.value)) {
return;
}
const unscaled = modelScale === 1;
if (unscaled) {
if (isOccluded !== null && isOccluded(boundingSphere)) {
return;
}
const hasInstanceGrid = r.values.instanceGrid.ref.value.cellSize > 0;
const hasMultipleInstances = r.values.uInstanceCount.ref.value > 1;
if (hasInstanceGrid && (hasMultipleInstances || r.values.lodLevels)) {
r.cull(cameraPlane, frustum, isOccluded, ctx.stats);
} else {
r.uncull();
const hasInstanceGrid = r.values.instanceGrid.ref.value.cellSize > 0;
const hasMultipleInstances = r.values.uInstanceCount.ref.value > 1;
if (hasInstanceGrid && (hasMultipleInstances || r.values.lodLevels)) {
r.cull(cameraPlane, frustum, isOccluded, ctx.stats);
} else {
r.uncull();
}
}
let needUpdate = false;
@@ -382,9 +405,12 @@ namespace Renderer {
ValueCell.updateIfChanged(globalUniforms.uIsOrtho, camera.state.mode === 'orthographic' ? 1 : 0);
ValueCell.update(globalUniforms.uViewOffset, camera.viewOffset.enabled ? Vec2.set(viewOffset, camera.viewOffset.offsetX * 16, camera.viewOffset.offsetY * 16) : Vec2.set(viewOffset, 0, 0));
ValueCell.updateIfChanged(globalUniforms.uModelScale, camera.state.scale);
ValueCell.update(globalUniforms.uCameraPosition, Vec3.copy(cameraPosition, camera.state.position));
ValueCell.update(globalUniforms.uCameraDir, Vec3.normalize(cameraDir, Vec3.sub(cameraDir, camera.state.target, camera.state.position)));
ValueCell.update(globalUniforms.uCameraPosition, Mat4.getTranslation(cameraPosition, invView));
const cameraTarget = Vec3.scale(Vec3(), camera.state.target, camera.state.scale);
Vec3.normalize(cameraDir, Vec3.sub(cameraDir, cameraTarget, cameraPosition));
ValueCell.update(globalUniforms.uCameraDir, cameraDir);
ValueCell.updateIfChanged(globalUniforms.uFar, camera.far);
ValueCell.updateIfChanged(globalUniforms.uNear, camera.near);
@@ -400,13 +426,26 @@ namespace Renderer {
ValueCell.update(globalUniforms.uCameraPlane, Plane3D.toArray(cameraPlane, globalUniforms.uCameraPlane.ref.value, 0));
ValueCell.updateIfChanged(globalUniforms.uMarkerAverage, scene.markerAverage);
const hasHeadRotation = !Mat4.isZero(camera.headRotation);
if (hasHeadRotation) {
ValueCell.updateIfChanged(globalUniforms.uHasHeadRotation, hasHeadRotation);
ValueCell.update(globalUniforms.uInvHeadRotation, Mat4.invert(invHeadRotation, camera.headRotation));
ValueCell.update(globalUniforms.uLightDirection, getTransformedLightDirection(light, invHeadRotation));
} else {
ValueCell.update(globalUniforms.uHasHeadRotation, false);
ValueCell.update(globalUniforms.uInvHeadRotation, Mat4.id);
ValueCell.update(globalUniforms.uLightDirection, light.direction);
}
};
const updateInternal = (group: Scene.Group, camera: ICamera, depthTexture: Texture | null, renderMask: Mask, markingDepthTest: boolean) => {
arrayMapUpsert(sharedTexturesList, 'tDepth', depthTexture || emptyDepthTexture);
ValueCell.update(globalUniforms.uModel, group.view);
ValueCell.update(globalUniforms.uModelView, Mat4.mul(modelView, camera.view, group.view));
modelScale = camera.state.scale;
ValueCell.update(globalUniforms.uModel, Mat4.scaleUniformly(model, group.view, camera.state.scale));
ValueCell.update(globalUniforms.uModelView, Mat4.mul(modelView, camera.view, model));
ValueCell.update(globalUniforms.uInvModelView, Mat4.invert(invModelView, modelView));
ValueCell.update(globalUniforms.uModelViewProjection, Mat4.mul(modelViewProjection, modelView, camera.projection));
ValueCell.update(globalUniforms.uInvModelViewProjection, Mat4.invert(invModelViewProjection, modelViewProjection));

View File

@@ -312,7 +312,6 @@ namespace Scene {
}
},
update(objects, keepBoundingSphere) {
Object3D.update(object3d);
if (objects) {
for (let i = 0, il = objects.length; i < il; ++i) {
renderableMap.get(objects[i])?.update();

View File

@@ -68,7 +68,6 @@ import { common } from './shader/chunks/common.glsl';
import { fade_lod } from './shader/chunks/fade-lod.glsl';
import { float_to_rgba } from './shader/chunks/float-to-rgba.glsl';
import { light_frag_params } from './shader/chunks/light-frag-params.glsl';
import { matrix_scale } from './shader/chunks/matrix-scale.glsl';
import { normal_frag_params } from './shader/chunks/normal-frag-params.glsl';
import { read_from_texture } from './shader/chunks/read-from-texture.glsl';
import { rgba_to_float } from './shader/chunks/rgba-to-float.glsl';
@@ -104,7 +103,6 @@ const ShaderChunks: { [k: string]: string } = {
fade_lod,
float_to_rgba,
light_frag_params,
matrix_scale,
normal_frag_params,
read_from_texture,
rgba_to_float,

View File

@@ -36,7 +36,7 @@ export const assign_color_varying = `
vec3 cgridPos = (uColorGridTransform.w * (position - uColorGridTransform.xyz)) / uColorGridDim;
vColor.rgb = texture3dFrom2dLinear(tColorGrid, cgridPos, uColorGridDim, uColorTexDim).rgb;
#elif defined(dColorType_volumeInstance)
vec3 cgridPos = (uColorGridTransform.w * (vModelPosition - uColorGridTransform.xyz)) / uColorGridDim;
vec3 cgridPos = (uColorGridTransform.w * (vModelPosition / uModelScale - uColorGridTransform.xyz)) / uColorGridDim;
vColor.rgb = texture3dFrom2dLinear(tColorGrid, cgridPos, uColorGridDim, uColorTexDim).rgb;
#endif
@@ -52,7 +52,7 @@ export const assign_color_varying = `
#elif defined(dOverpaintType_vertexInstance)
vOverpaint = readFromTexture(tOverpaint, int(aInstance) * uVertexCount + vertexId, uOverpaintTexDim);
#elif defined(dOverpaintType_volumeInstance)
vec3 ogridPos = (uOverpaintGridTransform.w * (vModelPosition - uOverpaintGridTransform.xyz)) / uOverpaintGridDim;
vec3 ogridPos = (uOverpaintGridTransform.w * (vModelPosition / uModelScale - uOverpaintGridTransform.xyz)) / uOverpaintGridDim;
vOverpaint = texture3dFrom2dLinear(tOverpaintGrid, ogridPos, uOverpaintGridDim, uOverpaintTexDim);
#endif
@@ -73,7 +73,7 @@ export const assign_color_varying = `
#elif defined(dEmissiveType_vertexInstance)
vEmissive = readFromTexture(tEmissive, int(aInstance) * uVertexCount + vertexId, uEmissiveTexDim).a;
#elif defined(dEmissiveType_volumeInstance)
vec3 egridPos = (uEmissiveGridTransform.w * (vModelPosition - uEmissiveGridTransform.xyz)) / uEmissiveGridDim;
vec3 egridPos = (uEmissiveGridTransform.w * (vModelPosition / uModelScale - uEmissiveGridTransform.xyz)) / uEmissiveGridDim;
vEmissive = texture3dFrom2dLinear(tEmissiveGrid, egridPos, uEmissiveGridDim, uEmissiveTexDim).a;
#endif
vEmissive *= uEmissiveStrength;
@@ -87,7 +87,7 @@ export const assign_color_varying = `
#elif defined(dSubstanceType_vertexInstance)
vSubstance = readFromTexture(tSubstance, int(aInstance) * uVertexCount + vertexId, uSubstanceTexDim);
#elif defined(dSubstanceType_volumeInstance)
vec3 sgridPos = (uSubstanceGridTransform.w * (vModelPosition - uSubstanceGridTransform.xyz)) / uSubstanceGridDim;
vec3 sgridPos = (uSubstanceGridTransform.w * (vModelPosition / uModelScale - uSubstanceGridTransform.xyz)) / uSubstanceGridDim;
vSubstance = texture3dFrom2dLinear(tSubstanceGrid, sgridPos, uSubstanceGridDim, uSubstanceTexDim);
#endif
@@ -104,7 +104,7 @@ export const assign_color_varying = `
#elif defined(dEmissiveType_vertexInstance)
vEmissive = readFromTexture(tEmissive, int(aInstance) * uVertexCount + vertexId, uEmissiveTexDim).a;
#elif defined(dEmissiveType_volumeInstance)
vec3 egridPos = (uEmissiveGridTransform.w * (vModelPosition - uEmissiveGridTransform.xyz)) / uEmissiveGridDim;
vec3 egridPos = (uEmissiveGridTransform.w * (vModelPosition / uModelScale - uEmissiveGridTransform.xyz)) / uEmissiveGridDim;
vEmissive = texture3dFrom2dLinear(tEmissiveGrid, egridPos, uEmissiveGridDim, uEmissiveTexDim).a;
#endif
vEmissive *= uEmissiveStrength;
@@ -133,7 +133,7 @@ export const assign_color_varying = `
#elif defined(dTransparencyType_vertexInstance)
vTransparency = readFromTexture(tTransparency, int(aInstance) * uVertexCount + vertexId, uTransparencyTexDim).a;
#elif defined(dTransparencyType_volumeInstance)
vec3 tgridPos = (uTransparencyGridTransform.w * (vModelPosition - uTransparencyGridTransform.xyz)) / uTransparencyGridDim;
vec3 tgridPos = (uTransparencyGridTransform.w * (vModelPosition / uModelScale - uTransparencyGridTransform.xyz)) / uTransparencyGridDim;
vTransparency = texture3dFrom2dLinear(tTransparencyGrid, tgridPos, uTransparencyGridDim, uTransparencyTexDim).a;
#endif
vTransparency *= uTransparencyStrength;

View File

@@ -56,6 +56,7 @@ varying vec3 vModelPosition;
varying vec3 vViewPosition;
uniform vec2 uViewOffset;
uniform float uModelScale;
uniform float uNear;
uniform float uFar;

View File

@@ -46,6 +46,8 @@ uniform int uPickType;
varying vec3 vModelPosition;
varying vec3 vViewPosition;
uniform float uModelScale;
#if defined(noNonInstancedActiveAttribs)
// int() is needed for some Safari versions
// see https://bugs.webkit.org/show_bug.cgi?id=244152

View File

@@ -1,12 +0,0 @@
/**
* Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
export const matrix_scale = `
float matrixScale(in mat4 m){
vec4 r = m[0];
return sqrt(r[0] * r[0] + r[1] * r[1] + r[2] * r[2]);
}
`;

View File

@@ -48,10 +48,10 @@ void main() {
mat4 modelTransform = uModel * aTransform;
vTransform = aTransform;
vTransform = modelTransform;
vStart = (modelTransform * vec4(aStart, 1.0)).xyz;
vEnd = (modelTransform * vec4(aEnd, 1.0)).xyz;
vSize = size * aScale;
vSize = size * aScale * uModelScale;
vCap = aCap;
vModelPosition = (vStart + vEnd) * 0.5;

View File

@@ -29,11 +29,12 @@ precision highp int;
uniform mat4 uProjection, uTransform, uModelView, uModel, uView;
uniform vec3 uCameraDir;
uniform float uModelScale;
uniform sampler2D tDepth;
uniform vec2 uDrawingBufferSize;
varying vec3 vOrigPos;
varying vec3 vModelPosition;
varying float vInstance;
varying vec4 vBoundingSphere;
varying mat4 vTransform;
@@ -212,7 +213,7 @@ vec4 raymarch(vec3 startLoc, vec3 step, vec3 rayDir) {
vec3 distVec = startLoc - pos;
if (dot(distVec, distVec) > maxDistSq) break;
unitPos = v3m4(pos, cartnToUnit);
unitPos = v3m4(pos / uModelScale, cartnToUnit);
// continue when outside of grid
if (unitPos.x > posMax.x || unitPos.y > posMax.y || unitPos.z > posMax.z ||
@@ -228,7 +229,7 @@ vec4 raymarch(vec3 startLoc, vec3 step, vec3 rayDir) {
if (uJumpLength > 0.0 && value < 0.01) {
nextPos = pos + rayDir * uJumpLength;
nextValue = textureVal(v3m4(nextPos, cartnToUnit)).a;
nextValue = textureVal(v3m4(nextPos / uModelScale, cartnToUnit)).a;
if (nextValue < 0.01) {
prevValue = nextValue;
pos = nextPos;
@@ -361,15 +362,15 @@ void main() {
if (gl_FrontFacing)
discard;
vec3 rayDir = mix(normalize(vOrigPos - uCameraPosition), uCameraDir, uIsOrtho);
vec3 step = rayDir * uStepScale;
vec3 rayDir = mix(normalize(vModelPosition - uCameraPosition), uCameraDir, uIsOrtho);
vec3 step = rayDir * uStepScale * uModelScale;
float boundingSphereNear = distance(vBoundingSphere.xyz, uCameraPosition) - vBoundingSphere.w;
float d = max(uNear, boundingSphereNear) - mix(0.0, distance(vOrigPos, uCameraPosition), uIsOrtho);
vec3 start = mix(uCameraPosition, vOrigPos, uIsOrtho) + (d * rayDir);
float d = max(uNear, boundingSphereNear) - mix(0.0, distance(vModelPosition, uCameraPosition), uIsOrtho);
vec3 start = mix(uCameraPosition, vModelPosition, uIsOrtho) + (d * rayDir);
gl_FragColor = raymarch(start, step, rayDir);
float fragmentDepth = calcDepth((uModelView * vec4(start, 1.0)).xyz);
float fragmentDepth = calcDepth((uView * vec4(start, 1.0)).xyz);
float preFogAlpha = clamp(preFogAlphaBlended, 0.0, 1.0);
#include wboit_write
#endif

View File

@@ -1,5 +1,5 @@
/**
* 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 Alexander Rose <alexander.rose@weirdbyte.de>
* @author Michael Krone <michael.krone@uni-tuebingen.de>
@@ -12,11 +12,13 @@ attribute vec3 aPosition;
attribute mat4 aTransform;
attribute float aInstance;
uniform mat4 uModel;
uniform mat4 uModelView;
uniform mat4 uProjection;
uniform vec4 uInvariantBoundingSphere;
uniform float uModelScale;
varying vec3 vOrigPos;
varying vec3 vModelPosition;
varying float vInstance;
varying vec4 vBoundingSphere;
varying mat4 vTransform;
@@ -33,11 +35,11 @@ void main() {
vec4 unitCoord = vec4(aPosition + vec3(0.5), 1.0);
vec4 mvPosition = uModelView * aTransform * uUnitToCartn * unitCoord;
vOrigPos = (aTransform * uUnitToCartn * unitCoord).xyz;
vModelPosition = (uModel * aTransform * uUnitToCartn * unitCoord).xyz;
vInstance = aInstance;
vBoundingSphere = vec4(
(aTransform * vec4(uInvariantBoundingSphere.xyz, 1.0)).xyz,
uInvariantBoundingSphere.w
(uModel * aTransform * vec4(uInvariantBoundingSphere.xyz, 1.0)).xyz,
uModelScale * uInvariantBoundingSphere.w
);
vTransform = aTransform;

View File

@@ -92,7 +92,7 @@ void main(void){
vec3 vViewPosition = -vPointViewPosition;
fragmentDepth = gl_FragCoord.z;
#if !defined(dIgnoreLight) || defined(dXrayShaded) || defined(dRenderVariant_tracing)
pointDir.z -= cos(length(pointDir));
pointDir.z -= cos(length(pointDir)) * vRadius * 0.5;
cameraNormal = -normalize(pointDir);
#endif
interior = false;

View File

@@ -18,6 +18,8 @@ precision highp int;
uniform mat4 uModelView;
uniform mat4 uInvProjection;
uniform float uIsOrtho;
uniform bool uHasHeadRotation;
uniform mat4 uInvHeadRotation;
uniform vec2 uTexDim;
uniform sampler2D tPositionGroup;
@@ -29,8 +31,6 @@ varying float vRadius;
varying vec3 vPoint;
varying vec3 vPointViewPosition;
#include matrix_scale
/**
* Bounding rectangle of a clipped, perspective-projected 3D Sphere.
* Michael Mara, Morgan McGuire. 2013
@@ -81,7 +81,7 @@ void main(void){
#include assign_clipping_varying
#include assign_size
vRadius = size * matrixScale(uModelView);
vRadius = size * uModelScale;
vec4 position4 = vec4(position, 1.0);
vModelPosition = (uModel * aTransform * position4).xyz; // for clipping in frag shader
@@ -107,6 +107,10 @@ void main(void){
vec4 mvCorner = vec4(mvPosition.xyz, 1.0);
mvCorner.xy += mapping * vRadius;
gl_Position = uProjection * mvCorner;
} else if (uHasHeadRotation) {
vec4 mvCorner = vec4(mvPosition.xyz, 1.0);
mvCorner.xy += mapping * vRadius * 1.4;
gl_Position = uProjection * mvCorner;
} else {
gl_Position = uProjection * vec4(mvPosition.xyz, 1.0);
sphereProjection(mvPosition.xyz, vRadius, mapping);

View File

@@ -32,11 +32,11 @@ uniform float uOffsetZ;
uniform float uIsOrtho;
uniform float uPixelRatio;
uniform vec4 uViewport;
uniform mat4 uInvHeadRotation;
uniform bool uHasHeadRotation;
varying vec2 vTexCoord;
#include matrix_scale
void main(void){
int vertexId = VertexID;
@@ -48,7 +48,7 @@ void main(void){
vTexCoord = aTexCoord;
float scale = matrixScale(uModelView);
float scale = uModelScale;
float offsetX = uOffsetX * scale;
float offsetY = uOffsetY * scale;
@@ -75,9 +75,16 @@ void main(void){
offsetZ -= 0.001 * distance(uCameraPosition, (uProjection * mvCorner).xyz);
}
mvCorner.xy += aMapping * size * scale;
mvCorner.x += offsetX;
mvCorner.y += offsetY;
vec3 cornerOffset = vec3(0.0);
cornerOffset.xy += aMapping * size * scale;
cornerOffset.x += offsetX;
cornerOffset.y += offsetY;
if (uHasHeadRotation) {
mvCorner.xyz += (uInvHeadRotation * vec4(cornerOffset, 1.0)).xyz;
} else {
mvCorner.xyz += cornerOffset;
}
if (uIsOrtho == 1.0) {
mvCorner.z += offsetZ;

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2022 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>
*/
@@ -12,12 +12,13 @@ import { assertUnreachable, ValueOf } from '../../mol-util/type-helpers';
import { GLRenderingContext, isWebGL2 } from './compat';
import { WebGLExtensions } from './extensions';
import { WebGLState } from './state';
import { getBytesPerElement, getFormat, getType, TextureFormat, TextureType } from './texture';
const getNextBufferId = idFactory();
export type UsageHint = 'static' | 'dynamic' | 'stream'
export type DataType = 'uint8' | 'int8' | 'uint16' | 'int16' | 'uint32' | 'int32' | 'float32'
export type BufferType = 'attribute' | 'elements' | 'uniform'
export type BufferType = 'attribute' | 'elements' | 'uniform' | 'pixel-pack'
export type DataTypeArrayType = {
'uint8': Uint8Array
@@ -36,6 +37,7 @@ export function getUsageHint(gl: GLRenderingContext, usageHint: UsageHint) {
case 'static': return gl.STATIC_DRAW;
case 'dynamic': return gl.DYNAMIC_DRAW;
case 'stream': return gl.STREAM_DRAW;
default: assertUnreachable(usageHint);
}
}
@@ -81,6 +83,12 @@ export function getBufferType(gl: GLRenderingContext, bufferType: BufferType) {
} else {
throw new Error('WebGL2 is required for uniform buffers');
}
case 'pixel-pack':
if (isWebGL2(gl)) {
return gl.PIXEL_PACK_BUFFER;
} else {
throw new Error('WebGL2 is required for pixel-pack buffers');
}
}
}
@@ -258,4 +266,63 @@ export function createElementsBuffer(gl: GLRenderingContext, array: ElementsType
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffer.getBuffer());
}
};
}
}
//
export interface PixelPackBuffer {
readonly id: number
readonly _type: number
readonly _format: number
readonly _bpe: number
read: (x: number, y: number, width: number, height: number) => void
getSubData: (array: ArrayType) => void
reset: () => void
destroy: () => void
}
export function createPixelPackBuffer(gl: WebGL2RenderingContext, extensions: WebGLExtensions, format: TextureFormat, type: TextureType): PixelPackBuffer {
let _buffer = getBuffer(gl);
const _type = getType(gl, extensions, type);
const _format = getFormat(gl, format, type);
const _bpe = getBytesPerElement(format, type);
function read(x: number, y: number, width: number, height: number) {
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, _buffer);
gl.bufferData(gl.PIXEL_PACK_BUFFER, width * height * _bpe, gl.STREAM_READ);
gl.readPixels(x, y, width, height, _format, _type, 0);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
}
function getSubData(array: ArrayType) {
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, _buffer);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, array);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
}
let destroyed = false;
return {
id: getNextBufferId(),
_type,
_format,
_bpe,
read,
getSubData,
reset: () => {
_buffer = getBuffer(gl);
},
destroy: () => {
if (destroyed) return;
gl.deleteBuffer(_buffer);
destroyed = true;
}
};
}

View File

@@ -182,6 +182,7 @@ function createStats() {
resourceCounts: {
attribute: 0,
elements: 0,
pixelPack: 0,
framebuffer: 0,
program: 0,
renderbuffer: 0,
@@ -253,7 +254,6 @@ export interface WebGLContext {
bindDrawingBuffer: () => void
getDrawingBufferSize: () => { width: number, height: number }
readPixels: (x: number, y: number, width: number, height: number, buffer: Uint8Array | Float32Array | Int32Array) => void
readPixelsAsync: (x: number, y: number, width: number, height: number, buffer: Uint8Array) => Promise<void>
waitForGpuCommandsComplete: () => Promise<void>
waitForGpuCommandsCompleteSync: () => void
getFenceSync: () => WebGLSync | null
@@ -304,43 +304,6 @@ export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScal
let pixelScale = props.pixelScale || 1;
let readPixelsAsync: (x: number, y: number, width: number, height: number, buffer: Uint8Array) => Promise<void>;
if (isWebGL2(gl)) {
const pbo = gl.createBuffer();
let _buffer: Uint8Array | undefined = void 0;
let _resolve: (() => void) | undefined = void 0;
let _reading = false;
const bindPBO = () => {
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, _buffer!);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
_reading = false;
_resolve!();
_resolve = void 0;
_buffer = void 0;
};
readPixelsAsync = (x: number, y: number, width: number, height: number, buffer: Uint8Array): Promise<void> => new Promise<void>((resolve, reject) => {
if (_reading) {
reject('Can not call multiple readPixelsAsync at the same time');
return;
}
_reading = true;
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
gl.bufferData(gl.PIXEL_PACK_BUFFER, width * height * 4, gl.STREAM_READ);
gl.readPixels(x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, 0);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
// need to unbind/bind PBO before/after async awaiting the fence
_resolve = resolve;
_buffer = buffer;
fence(gl, bindPBO);
});
} else {
readPixelsAsync = async (x: number, y: number, width: number, height: number, buffer: Uint8Array) => {
readPixels(gl, x, y, width, height, buffer);
};
}
const renderTargets = new Set<RenderTarget>();
return {
@@ -429,7 +392,6 @@ export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScal
readPixels: (x: number, y: number, width: number, height: number, buffer: Uint8Array | Float32Array | Int32Array) => {
readPixels(gl, x, y, width, height, buffer);
},
readPixelsAsync,
waitForGpuCommandsComplete: () => waitForGpuCommandsComplete(gl),
waitForGpuCommandsCompleteSync: () => waitForGpuCommandsCompleteSync(gl),
getFenceSync: () => {

View File

@@ -6,11 +6,11 @@
import { ProgramProps, createProgram, Program } from './program';
import { ShaderType, createShader, Shader, ShaderProps } from './shader';
import { GLRenderingContext } from './compat';
import { GLRenderingContext, isWebGL2 } from './compat';
import { Framebuffer, createFramebuffer } from './framebuffer';
import { WebGLExtensions } from './extensions';
import { WebGLState } from './state';
import { AttributeBuffer, UsageHint, ArrayType, AttributeItemSize, createAttributeBuffer, ElementsBuffer, createElementsBuffer, ElementsType, AttributeBuffers } from './buffer';
import { AttributeBuffer, UsageHint, ArrayType, AttributeItemSize, createAttributeBuffer, ElementsBuffer, createElementsBuffer, ElementsType, AttributeBuffers, PixelPackBuffer, createPixelPackBuffer } from './buffer';
import { createReferenceCache, ReferenceItem } from '../../mol-util/reference-cache';
import { WebGLStats } from './context';
import { hashString, hashFnv32a } from '../../mol-data/util';
@@ -54,6 +54,7 @@ type ByteCounts = {
export interface WebGLResources {
attribute: (array: ArrayType, itemSize: AttributeItemSize, divisor: number, usageHint?: UsageHint) => AttributeBuffer
elements: (array: ElementsType, usageHint?: UsageHint) => ElementsBuffer
pixelPack: (format: TextureFormat, type: TextureType) => PixelPackBuffer
framebuffer: () => Framebuffer
program: (defineValues: DefineValues, shaderCode: ShaderCode, schema: RenderableSchema) => Program
renderbuffer: (format: RenderbufferFormat, attachment: RenderbufferAttachment, width: number, height: number) => Renderbuffer
@@ -72,6 +73,7 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
const sets: { [k in ResourceName]: Set<Resource> } = {
attribute: new Set<Resource>(),
elements: new Set<Resource>(),
pixelPack: new Set<Resource>(),
framebuffer: new Set<Resource>(),
program: new Set<Resource>(),
renderbuffer: new Set<Resource>(),
@@ -126,6 +128,12 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
elements: (array: ElementsType, usageHint?: UsageHint) => {
return wrap('elements', createElementsBuffer(gl, array, usageHint));
},
pixelPack: (format: TextureFormat, type: TextureType) => {
if (!isWebGL2(gl)) {
throw new Error('WebGL2 is required for pixel-pack buffers');
}
return wrap('pixelPack', createPixelPackBuffer(gl, extensions, format, type));
},
framebuffer: () => {
return wrap('framebuffer', createFramebuffer(gl));
},
@@ -171,6 +179,7 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
reset: () => {
sets.attribute.forEach(r => r.reset());
sets.elements.forEach(r => r.reset());
sets.pixelPack.forEach(r => r.reset());
sets.framebuffer.forEach(r => r.reset());
sets.renderbuffer.forEach(r => r.reset());
sets.shader.forEach(r => r.reset());
@@ -182,6 +191,7 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
destroy: () => {
sets.attribute.forEach(r => r.destroy());
sets.elements.forEach(r => r.destroy());
sets.pixelPack.forEach(r => r.destroy());
sets.framebuffer.forEach(r => r.destroy());
sets.renderbuffer.forEach(r => r.destroy());
sets.shader.forEach(r => r.destroy());

View File

@@ -118,8 +118,11 @@ export function getInternalFormat(gl: GLRenderingContext, format: TextureFormat,
}
function getByteCount(format: TextureFormat, type: TextureType, width: number, height: number, depth: number): number {
const bpe = getFormatSize(format) * getTypeSize(type);
return bpe * width * height * (depth || 1);
return getBytesPerElement(format, type) * width * height * (depth || 1);
}
export function getBytesPerElement(format: TextureFormat, type: TextureType): number {
return getFormatSize(format) * getTypeSize(type);
}
function getFormatSize(format: TextureFormat) {

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

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Gianluca Tomasello <giagitom@gmail.com>
*/
import { Vec3 } from '../../linear-algebra';
import { Box3D } from '../primitives/box3d';
import { Ray3D } from '../primitives/ray3d';
describe('ray3d', () => {
it('intersectBox3D', () => {
const box = Box3D.create(Vec3.create(-1, -1, -1), Vec3.create(1, 1, 1));
const out = Vec3();
// 1. Ray starts outside and hits the box frontally
const ray1 = Ray3D.create(Vec3.create(-2, 0, 0), Vec3.create(1, 0, 0));
expect(Ray3D.intersectBox3D(out, ray1, box)).toBe(true);
expect(out).toEqual(Vec3.create(-1, 0, 0));
// 2. Ray grazes along the top edge (tangential)
const ray2 = Ray3D.create(Vec3.create(-2, 1, 0), Vec3.create(1, 0, 0));
expect(Ray3D.intersectBox3D(out, ray2, box)).toBe(true);
expect(out).toEqual(Vec3.create(-1, 1, 0));
// 3. Ray starts exactly on the surface and goes inward
const ray3 = Ray3D.create(Vec3.create(-1, 0, 0), Vec3.create(1, 0, 0));
expect(Ray3D.intersectBox3D(out, ray3, box)).toBe(true);
expect(out).toEqual(Vec3.create(-1, 0, 0));
// 4. Ray grazes a corner exactly
const ray4 = Ray3D.create(Vec3.create(-2, -2, -2), Vec3.create(1, 1, 1));
expect(Ray3D.intersectBox3D(out, ray4, box)).toBe(true);
expect(out).toEqual(Vec3.create(-1, -1, -1));
// 5. Ray starts inside the box and exits
const ray5 = Ray3D.create(Vec3.create(0, 0, 0), Vec3.create(1, 0, 0));
expect(Ray3D.intersectBox3D(out, ray5, box)).toBe(false);
// 6. Ray starts outside and points away (misses box completely)
const ray6 = Ray3D.create(Vec3.create(-2, 2, 0), Vec3.create(1, 0, 0));
expect(Ray3D.intersectBox3D(out, ray6, box)).toBe(false);
});
});

View File

@@ -418,7 +418,7 @@ function queryNearest<T extends number = number>(ctx: QueryContext, result: Resu
if (!Box3D.containsVec3(box, tmpRay.origin)) {
// intersect ray pointing to box center
Ray3D.targetTo(tmpRay, tmpRay, center);
Box3D.nearestIntersectionWithRay3D(tmpRay.origin, box, tmpRay);
Ray3D.intersectBox3D(tmpRay.origin, tmpRay, box);
gX = Math.max(0, Math.min(sX - 1, Math.floor((tmpRay.origin[0] - min[0]) / delta[0])));
gY = Math.max(0, Math.min(sY - 1, Math.floor((tmpRay.origin[1] - min[1]) / delta[1])));
gZ = Math.max(0, Math.min(sZ - 1, Math.floor((tmpRay.origin[2] - min[2]) / delta[2])));

View File

@@ -10,7 +10,6 @@ import { OrderedSet } from '../../../mol-data/int';
import { Sphere3D } from './sphere3d';
import { Vec3 } from '../../linear-algebra/3d/vec3';
import { Mat4 } from '../../linear-algebra/3d/mat4';
import { Ray3D } from './ray3d';
interface Box3D { min: Vec3, max: Vec3 }
@@ -191,48 +190,6 @@ namespace Box3D {
) ? false : true;
}
export function nearestIntersectionWithRay3D(out: Vec3, box: Box3D, ray: Ray3D): Vec3 {
const { origin, direction } = ray;
const [minX, minY, minZ] = box.min;
const [maxX, maxY, maxZ] = box.max;
const [x, y, z] = origin;
const invDirX = 1.0 / direction[0];
const invDirY = 1.0 / direction[1];
const invDirZ = 1.0 / direction[2];
let tmin, tmax, tymin, tymax, tzmin, tzmax;
if (invDirX >= 0) {
tmin = (minX - x) * invDirX;
tmax = (maxX - x) * invDirX;
} else {
tmin = (maxX - x) * invDirX;
tmax = (minX - x) * invDirX;
}
if (invDirY >= 0) {
tymin = (minY - y) * invDirY;
tymax = (maxY - y) * invDirY;
} else {
tymin = (maxY - y) * invDirY;
tymax = (minY - y) * invDirY;
}
if (invDirZ >= 0) {
tzmin = (minZ - z) * invDirZ;
tzmax = (maxZ - z) * invDirZ;
} else {
tzmin = (maxZ - z) * invDirZ;
tzmax = (minZ - z) * invDirZ;
}
if (tymin > tmin)
tmin = tymin;
if (tymax < tmax)
tmax = tymax;
if (tzmin > tmin)
tmin = tzmin;
if (tzmax < tmax)
tmax = tzmax;
Vec3.scale(out, direction, tmin);
return Vec3.set(out, out[0] + x, out[1] + y, out[2] + z);
}
export function center(out: Vec3, box: Box3D): Vec3 {
return Vec3.center(out, box.max, box.min);
}

View File

@@ -2,10 +2,13 @@
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
*/
import { Mat4 } from '../../linear-algebra/3d/mat4';
import { Vec3 } from '../../linear-algebra/3d/vec3';
import { Box3D } from './box3d';
import { Sphere3D } from './sphere3d';
interface Ray3D { origin: Vec3, direction: Vec3 }
@@ -38,6 +41,96 @@ namespace Ray3D {
Vec3.transformDirection(out.direction, ray.direction, m);
return out;
}
//
const tmpIR = Vec3();
function _intersectSphere3D(ray: Ray3D, sphere: Sphere3D): number {
const { center, radius } = sphere;
const { origin, direction } = ray;
const oc = Vec3.sub(tmpIR, origin, center);
const a = Vec3.dot(direction, direction);
const b = 2.0 * Vec3.dot(oc, direction);
const c = Vec3.dot(oc, oc) - radius * radius;
const discriminant = b * b - 4 * a * c;
if (discriminant < 0) return -1; // no intersection
const t = (-b - Math.sqrt(discriminant)) / (2.0 * a);
if (t < 0) return -1; // behind the ray
return t;
}
export function intersectSphere3D(out: Vec3, ray: Ray3D, sphere: Sphere3D): boolean {
const t = _intersectSphere3D(ray, sphere);
if (t < 0) return false;
Vec3.scaleAndAdd(out, ray.origin, ray.direction, t);
return true;
}
export function isIntersectingSphere3D(ray: Ray3D, sphere: Sphere3D): boolean {
return _intersectSphere3D(ray, sphere) >= 0;
}
export function isInsideSphere3D(ray: Ray3D, sphere: Sphere3D): boolean {
return Vec3.distance(ray.origin, sphere.center) < sphere.radius;
}
//
function _intersectBox3D(ray: Ray3D, box: Box3D): number {
const { origin, direction } = ray;
const [minX, minY, minZ] = box.min;
const [maxX, maxY, maxZ] = box.max;
const [x, y, z] = origin;
const invDirX = 1.0 / direction[0];
const invDirY = 1.0 / direction[1];
const invDirZ = 1.0 / direction[2];
let tmin, tmax, tymin, tymax, tzmin, tzmax;
if (invDirX >= 0) {
tmin = (minX - x) * invDirX;
tmax = (maxX - x) * invDirX;
} else {
tmin = (maxX - x) * invDirX;
tmax = (minX - x) * invDirX;
}
if (invDirY >= 0) {
tymin = (minY - y) * invDirY;
tymax = (maxY - y) * invDirY;
} else {
tymin = (maxY - y) * invDirY;
tymax = (minY - y) * invDirY;
}
if ((tmin > tymax) || (tymin > tmax)) return -1;
if (tymin > tmin) tmin = tymin;
if (tymax < tmax) tmax = tymax;
if (invDirZ >= 0) {
tzmin = (minZ - z) * invDirZ;
tzmax = (maxZ - z) * invDirZ;
} else {
tzmin = (maxZ - z) * invDirZ;
tzmax = (minZ - z) * invDirZ;
}
if ((tmin > tzmax) || (tzmin > tmax)) return -1;
if (tzmin > tmin) tmin = tzmin;
if (tzmax < tmax) tmax = tzmax;
return tmin >= 0 ? tmin : -1;
}
export function intersectBox3D(out: Vec3, ray: Ray3D, box: Box3D): boolean {
const t = _intersectBox3D(ray, box);
if (t < 0) return false;
Vec3.scaleAndAdd(out, ray.origin, ray.direction, t);
return true;
}
export function isIntersectingBox3D(ray: Ray3D, box: Box3D): boolean {
return _intersectBox3D(ray, box) >= 0;
}
}
export { Ray3D };

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2022 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>
@@ -109,6 +109,23 @@ namespace Sphere3D {
return out;
}
/** Scale sphere by a number */
export function scale(out: Sphere3D, sphere: Sphere3D, s: number) {
Vec3.scale(out.center, sphere.center, s);
out.radius = sphere.radius * s;
if (hasExtrema(sphere)) {
setExtrema(out, sphere.extrema.map(e => Vec3.scale(Vec3(), e, s)));
}
return out;
}
/** Scale sphere by a number but without extrema */
export function scaleNX(out: Sphere3D, sphere: Sphere3D, s: number) {
Vec3.scale(out.center, sphere.center, s);
out.radius = sphere.radius * s;
return out;
}
export function toArray<T extends NumberArray>(s: Sphere3D, out: T, offset: number) {
Vec3.toArray(s.center, out, offset);
out[offset + 3] = s.radius;

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

@@ -84,6 +84,11 @@ namespace Mat4 {
return mat;
}
export function isZero(mat: Mat4): boolean {
for (let i = 0; i < 16; i++) if (mat[i] !== 0) return false;
return true;
}
export function setZero(mat: Mat4): Mat4 {
for (let i = 0; i < 16; i++) mat[i] = 0;
return mat;
@@ -1265,6 +1270,14 @@ namespace Mat4 {
return Math.sqrt(Math.max(scaleXSq, scaleYSq, scaleZSq));
}
export function extractBasis(m: Mat4) {
return {
x: Vec3.create(m[0], m[1], m[2]),
y: Vec3.create(m[4], m[5], m[6]),
z: Vec3.create(m[8], m[9], m[10])
};
}
const xAxis = [1, 0, 0] as unknown as Vec3;
const yAxis = [0, 1, 0] as unknown as Vec3;
const zAxis = [0, 0, 1] as unknown as Vec3;

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Kim Juho <juho_kim@outlook.com>
*/
import { CifField } from '../../../mol-io/reader/cif';
@@ -94,8 +95,10 @@ export function addAnisotropic(sites: AnisotropicTemplate, model: string, data:
TokenBuilder.add(sites.pdbx_label_alt_id, s + 16, s + 17);
}
// 18 - 20 Residue name resName Residue name.
TokenBuilder.addToken(sites.pdbx_auth_comp_id, Tokenizer.trim(data, s + 17, s + 20));
// 18 - 21 Residue name resName Residue name.
// PDB spec defines 3-letter
// but 4-letter are commonly used
TokenBuilder.addToken(sites.pdbx_auth_comp_id, Tokenizer.trim(data, s + 17, s + 21));
// 22 Character chainID Chain identifier.
TokenBuilder.add(sites.pdbx_auth_asym_id, s + 21, s + 22);

View File

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

View File

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

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