Compare commits

...

60 Commits

Author SHA1 Message Date
Alexander Rose
ba944769f5 Add loadUrl method and GET params to Viewer app 2026-06-06 08:38:54 -07:00
Russell Taylor
5708908b66 Kinemage file loading (#1806)
* Adding KIN loading to mesoscale app

* Initial pull-in of NGL Kinemage parser code. It is called by parser.ts and counts of objects are printed.

* Moving interface definitions to scheme.ts for Kin file reader.

* Organizing and commenting list structures. Specifying types where known.

* Removing copied PLY parsing and geometry generation code from KIN file reader. Passing the Kinemage data structure from the parser to the geometry generator. Stubs now in place for KIN with no geometry currently being generated. Also updated the kin.spec.ts file to check a Kinemage file.

* Initial construction of lines from vector lists. Still need to do multiple vector lists, colors, labels, and more.

* Initial implementation of converting vectors into lines. Still needs groups, colors, labels, etc.

* Duplicating ANVIL structure in a kinemage extension directory as a basis for a new Kinemage loading extension

Trying to stick with the master code, which has changed a lot.

* Modifying the kinemage parser to be able to read more than one kinemage entry from the same file. Hooking in a drag and drop handler on .kin files that for now just stores things into a global variable. Adjusting the parsing of kinemage through the original plugin path to handle the new parsing.

* Renaming for clarity

* Fixing name on KinemageDataProvider

* Changing name to kinemage

* First working Kinemage extension code that can draw lines from all drag-and-drop kinemages

* Renaming the Kinemage shape provider pipeline to include the name lines so we can make separate ones for meshes and balls

* Kinemage drag-and-drop handler now shows both lines and ribbons

* Also draws points for dotLists in Kinemage

* Cleaning up nesting and variables

* More cleanup

* Cleaning up

* Continued cleanup

* More work but less fragile

* Only reporting an opened file if we get a kinemage

* Reducing the number of objects and commits

* Fixing over-counting of points

* Adding sphere generation for BallList in Kinemage files. Added reading of radius from list line to enable list-wide specification

* Adding control over line width. Allowing short forms of list names. Working on passing color through

* Fixing width code on vectors. Cleaning up color code

* Setting Kinemage line radius as half the width, clamped to a minimum of 1.0

* Handling @colorset lines in Kinemage. Reporting when we have an unrecognized list element.

* Adding line coloring.

* Enabling support for coloring of ribbons, including rendering both sides with the same color.

* Fixing per-group coloring on meshes and cleaning up

* Adding per-dot coloring

* Adding per-sphere coloring

* Adding labels to elements loaded from Kinemage

* Making Kinemage ribbons have the same normal for every pair of triangles

* Adding README.md for Kinemage extension

* Updating README

* Updating README

* Factoring out file loading from drag and drop handler

* Starting to implement standard file loading for .kin files

* Wraps the text in a file when loading, but this causes it to be parsed twice.

* Hack of commenting out the visuals to make it only parse once

* Removing de-duplication code

* Simplifying function

* Updating comments

* Cleaning up unused objects

* Removing usused Preset

* Removing unused objects left over from original code copied from

* Removing unused objects

* Simplifying function

* Enabling specifying the name of a geometry type loaded by a Kinemage. Not adding entries for object types that are empty lists

* Naming the GUI elements after the PDB file if it is specified in the Kinemage file

* Updating README

* Adding parsing of view parameters from Kinemage

* Constructing Camera.Snapshot objects for each Kinemage View.

* Adding GUI elements to select Views when they are present in the Kinemage file.

* Transposing the orientation matrix to match Mol* orientation

* Changing the name of the view selection GUI elements to match the view that they provide.

* Tweak

* Updating README

* Removing obsolete comment

* Separating the parsing and geometry generation for kinemages

* Updating default visibility when parsing kinemage files. Adding master controls whose visibility icons toggle the state. This does not yet change the visibility of objects in the scene

* Control the geometry generation in kinemages based on the visibility of masters for each list. This is not yet tied into changes caused by the visibility buttons, but it now respects the initial states of the masters in the kinemage file.

* Adding off entry for groups and subgroups that defaults to false

* Adding group and subgroup visibility calculations to kinemage files

* Keeping track of the shapes that are created for a kinemage

* Factoring out shape creation function so we can call it again later. Keeping track of kinData

* Master visibility now working, though it causes view recentering. Removed spurious calls from view adjustment but still happening

* Removing obsolete view code

* Keep the viewpoint from changing when we make masters visible and invisible

* Don't repeat kinemage construction when a later file is loaded

* Ghosting the visibility controls for shapes in kinemages because they will be controlled by the masters and groups

* Split each vector in half, label and color each half by the nearest endpoint. This makes the pop-up labels match what is expected

* Updating Kinemage README with new capabilities

* Adding group visibility controls.

* Cleaning up the visibility calculations for both masters and groups

* Adding subgroup visibility controls

* Orders GUI elements so that subgroups after their group

* Handling 'nobutton' keyword when parsing and also fixing the display of GUI elements

* Updating README

* Turning all but the first group that is in animate off

* Removing unused parameter

* Removing spurious declaration

* Fixing parsing of nobutton tag on list

* Destroy old objects when we change visibility rather than just hiding them

* Make the Transforms associated with the geometry into ghosts so they don't show up in the GUI

* Adding animate and 2animate buttons that do not yet adjust the GUI state to track the changes

* Strating down the path of handling GUI updates with animation

* Animation toggles visibility checkboxes on the groups as it runs.

* Removing obsolete @todo comments

* Removing initial plugin-based Kinemage reader stubs, leaving the extension that handles both File/Open and drag-and-drop

* Overwriting package-lock.json based on new build

* Fixing assignments to handle strings or string arrays to allow the code to compile

* Adding KinemageExtension to viewer app

* This version requires us not to flip the winding numbers of every other triangle so that the colors match.

* Updating contributer documentation

* Removing obsolete entry

* Fixing author tags in documents

* Removing extra line added to file.

* Removing grammar fix and carriage return at end of file.

* Revering whitespace edits.

* Moving reader code for kinemage into its extensions directory

* Moving kin.ts into extensions/kinemage

* Starting down the path of moving the Kinemage GUI controls to the right-side panel. Puts the placeholder there but now shows only part of the geometry and does not see any Kinemage data.

* Continuing to implement controls on the right.

* Visibility of Kinemage controls now working and they are showing up in the right-hand control panel.

* Putting back animation controls and maintaining views across visibility changes

* Allow loading of multiple kinemages, seeing the controls for all of them.

* Comment and README changes

* Comment change

* Initial Kinemage commit that copies the PLY files and references to make it possible to load a PLY-formate file from a file with a KIN extension.

Overwriting package-lock.json

* Adding KIN loading to mesoscale app

* Initial pull-in of NGL Kinemage parser code. It is called by parser.ts and counts of objects are printed.

* Moving interface definitions to scheme.ts for Kin file reader.

* Organizing and commenting list structures. Specifying types where known.

* Removing copied PLY parsing and geometry generation code from KIN file reader. Passing the Kinemage data structure from the parser to the geometry generator. Stubs now in place for KIN with no geometry currently being generated. Also updated the kin.spec.ts file to check a Kinemage file.

* Initial construction of lines from vector lists. Still need to do multiple vector lists, colors, labels, and more.

* Initial implementation of converting vectors into lines. Still needs groups, colors, labels, etc.

* Duplicating ANVIL structure in a kinemage extension directory as a basis for a new Kinemage loading extension

Trying to stick with the master code, which has changed a lot.

* Modifying the kinemage parser to be able to read more than one kinemage entry from the same file. Hooking in a drag and drop handler on .kin files that for now just stores things into a global variable. Adjusting the parsing of kinemage through the original plugin path to handle the new parsing.

* Renaming for clarity

* Fixing name on KinemageDataProvider

* Changing name to kinemage

* First working Kinemage extension code that can draw lines from all drag-and-drop kinemages

* Renaming the Kinemage shape provider pipeline to include the name lines so we can make separate ones for meshes and balls

* Kinemage drag-and-drop handler now shows both lines and ribbons

* Also draws points for dotLists in Kinemage

* Cleaning up nesting and variables

* More cleanup

* Cleaning up

* Continued cleanup

* More work but less fragile

* Only reporting an opened file if we get a kinemage

* Reducing the number of objects and commits

* Fixing over-counting of points

* Adding sphere generation for BallList in Kinemage files. Added reading of radius from list line to enable list-wide specification

* Adding control over line width. Allowing short forms of list names. Working on passing color through

* Fixing width code on vectors. Cleaning up color code

* Setting Kinemage line radius as half the width, clamped to a minimum of 1.0

* Handling @colorset lines in Kinemage. Reporting when we have an unrecognized list element.

* Adding line coloring.

* Enabling support for coloring of ribbons, including rendering both sides with the same color.

* Fixing per-group coloring on meshes and cleaning up

* Adding per-dot coloring

* Adding per-sphere coloring

* Adding labels to elements loaded from Kinemage

* Making Kinemage ribbons have the same normal for every pair of triangles

* Adding README.md for Kinemage extension

* Updating README

* Updating README

* Factoring out file loading from drag and drop handler

* Starting to implement standard file loading for .kin files

* Wraps the text in a file when loading, but this causes it to be parsed twice.

* Hack of commenting out the visuals to make it only parse once

* Removing de-duplication code

* Simplifying function

* Updating comments

* Cleaning up unused objects

* Removing usused Preset

* Removing unused objects left over from original code copied from

* Removing unused objects

* Simplifying function

* Enabling specifying the name of a geometry type loaded by a Kinemage. Not adding entries for object types that are empty lists

* Naming the GUI elements after the PDB file if it is specified in the Kinemage file

* Updating README

* Adding parsing of view parameters from Kinemage

* Constructing Camera.Snapshot objects for each Kinemage View.

* Adding GUI elements to select Views when they are present in the Kinemage file.

* Transposing the orientation matrix to match Mol* orientation

* Changing the name of the view selection GUI elements to match the view that they provide.

* Tweak

* Updating README

* Removing obsolete comment

* Separating the parsing and geometry generation for kinemages

* Updating default visibility when parsing kinemage files. Adding master controls whose visibility icons toggle the state. This does not yet change the visibility of objects in the scene

* Control the geometry generation in kinemages based on the visibility of masters for each list. This is not yet tied into changes caused by the visibility buttons, but it now respects the initial states of the masters in the kinemage file.

* Adding off entry for groups and subgroups that defaults to false

* Adding group and subgroup visibility calculations to kinemage files

* Keeping track of the shapes that are created for a kinemage

* Factoring out shape creation function so we can call it again later. Keeping track of kinData

* Master visibility now working, though it causes view recentering. Removed spurious calls from view adjustment but still happening

* Removing obsolete view code

* Keep the viewpoint from changing when we make masters visible and invisible

* Don't repeat kinemage construction when a later file is loaded

* Ghosting the visibility controls for shapes in kinemages because they will be controlled by the masters and groups

* Split each vector in half, label and color each half by the nearest endpoint. This makes the pop-up labels match what is expected

* Updating Kinemage README with new capabilities

* Adding group visibility controls.

* Cleaning up the visibility calculations for both masters and groups

* Adding subgroup visibility controls

* Orders GUI elements so that subgroups after their group

* Handling 'nobutton' keyword when parsing and also fixing the display of GUI elements

* Updating README

* Turning all but the first group that is in animate off

* Removing unused parameter

* Removing spurious declaration

* Fixing parsing of nobutton tag on list

* Destroy old objects when we change visibility rather than just hiding them

* Make the Transforms associated with the geometry into ghosts so they don't show up in the GUI

* Adding animate and 2animate buttons that do not yet adjust the GUI state to track the changes

* Strating down the path of handling GUI updates with animation

* Animation toggles visibility checkboxes on the groups as it runs.

* Removing obsolete @todo comments

* Removing initial plugin-based Kinemage reader stubs, leaving the extension that handles both File/Open and drag-and-drop

* Fixing assignments to handle strings or string arrays to allow the code to compile

* Adding KinemageExtension to viewer app

* This version requires us not to flip the winding numbers of every other triangle so that the colors match.

* Updating contributer documentation

* Removing obsolete entry

* Fixing author tags in documents

* Removing extra line added to file.

* Removing grammar fix and carriage return at end of file.

* Revering whitespace edits.

* Moving reader code for kinemage into its extensions directory

* Moving kin.ts into extensions/kinemage

* Starting down the path of moving the Kinemage GUI controls to the right-side panel. Puts the placeholder there but now shows only part of the geometry and does not see any Kinemage data.

* Continuing to implement controls on the right.

* Visibility of Kinemage controls now working and they are showing up in the right-hand control panel.

* Putting back animation controls and maintaining views across visibility changes

* Allow loading of multiple kinemages, seeing the controls for all of them.

* Comment and README changes

* Comment change

* Reverting changes to central files that I thought I had to make to get the code to compile on a previous master checkout. The repository compiles without them now.

* Reverting changes made to get things to compile on an earlier master branch

* Reverting another change no longer needed.

* Converting Kinemage parser to using Color rather than number[] and moving HSV conversion into standard location

* Renaming file and removing commented-out code.

* Removing global state and using Transforms instead. Unregistering right-hand-side GUI objects when their associated State Tree objects are deleted.

* Removing unused activeKinemage index

* Adding subgroup visibility controls under groups when appropriate

* Adding * in front of animation groups

* Set background color to black when selecting a Kinemage view

* Updating README

* Implementing @pointmaster behavior properly.

* Re-styling the Kinemage extension right-hand UI to better match MolStar style

* changelog

* move spec

* lint/format

* Refactoring to make the data in the tree nodes immutable and use transforms with parameters to control visibility. This version works but when the animate button is pushed it switches any toggled visibility on other checkboxes back to the initial state

* Keep the animate and animate2 buttons from toggling other state

* Removing commented-out code

* Enable loading multiple kinemages in the same file

* Picking out lint

* Cleaning up test code

* Adding complex test that looks at the various keywords to make sure they are all working.

---------

Co-authored-by: Alexander Rose <alexander.rose@weirdbyte.de>
2026-06-06 08:26:56 -07:00
midlik
fe996870cc Download from AFDB - allow IDs with version suffix (#1845)
* Update param label and help for Download Structure From AlphaFoldDB

* Download Structure From AlphaFoldDB - allow IDs with version suffix (version to be ignored)

* Update CHANGELOG
2026-06-05 15:09:10 +02:00
Alexander Rose
93a3eba66d Merge pull request #1834 from molstar/fix-aromatic-ring-hybridization
Fix aromatic ring detection not accounting for hybridization
2026-05-30 21:43:10 -07:00
Alexander Rose
41b8584fb7 Merge branch 'master' of https://github.com/molstar/molstar into fix-aromatic-ring-hybridization 2026-05-30 21:40:31 -07:00
Alexander Rose
523b17dfde Merge pull request #1824 from sbittrich/master
Non-covalent interactions: detect and visualize water bridges
2026-05-30 21:38:35 -07:00
Alexander Rose
c47b4d6078 Merge pull request #1833 from molstar/cam-anim-params
Add axis param to camera spin/rock animation
2026-05-30 21:32:01 -07:00
Alexander Rose
b94073b96f Merge branch 'master' of https://github.com/molstar/molstar into cam-anim-params 2026-05-30 21:27:45 -07:00
Alexander Rose
905eb3ec2f add default for backwards compatibility 2026-05-30 21:26:28 -07:00
Sebastian
3ae72e5c60 generic bridge visuals 2026-05-29 10:55:48 +02:00
Alexander Rose
055dfd4946 Merge pull request #1840 from giagitom/fix-premul-rgb
Fix exported image artifacts on transparent background
2026-05-26 21:58:08 -07:00
Sebastian
2601d2ba63 decouple water bridges from hbond detection 2026-05-26 16:35:38 +02:00
Sebastian
340806d774 generalized support of interaction bridges 2026-05-26 16:06:14 +02:00
Sebastian
18ad848de2 Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	CHANGELOG.md
2026-05-26 13:57:53 +02:00
giagitom
9de8334af5 Fix exported image artifacts on transparent background 2026-05-26 13:05:24 +02:00
Alexander Rose
57580a5e6b Merge pull request #1836 from giagitom/fix-cel-shading-ambient-color
Fix cel-shaded ambient color being stripped to luminance
2026-05-23 21:50:07 -07:00
giagitom
7da4a85459 Fix cel-shaded ambient color being stripped to luminance 2026-05-19 16:43:00 +02:00
Alexander Rose
b7c380fd90 Merge branch 'master' of https://github.com/molstar/molstar into fix-aromatic-ring-hybridization 2026-05-17 22:21:57 -07:00
Alexander Rose
bcd304d058 header 2026-05-17 22:19:04 -07:00
Alexander Rose
fd50a8f8e0 Fix aromatic ring detection not accounting for hybridization 2026-05-17 22:17:55 -07:00
Alexander Rose
27f251e8e4 Merge pull request #1832 from molstar/ssao-multi-fix
Fix SSAO half/quarter resolution textures for multi-scale
2026-05-17 20:15:09 -07:00
Alexander Rose
8d2a44983e remove superfluous enableAnimation param 2026-05-16 22:29:55 -07:00
Alexander Rose
f806ac1444 Add axis param to camera spin/rock animation 2026-05-16 22:25:55 -07:00
Alexander Rose
63a585d88a Merge pull request #1830 from josemduarte/ms-fix-omitwater
Fix ModelServer bugs for omitWater param in surroundingLigands endpoint
2026-05-16 22:24:58 -07:00
Alexander Rose
a4b5a16fcd Merge branch 'master' into ms-fix-omitwater 2026-05-16 22:22:42 -07:00
Alexander Rose
86bf859a63 Fix SSAO half/quarter resolution textures for multi-scale 2026-05-16 22:15:12 -07:00
Alexander Rose
1b8117d3f1 Fix Volume and Isosurface getBoundingSphere ignoring instances 2026-05-10 17:18:18 -07:00
Alexander Rose
400e2bbc45 Merge pull request #1822 from corredD/codex/dot-morton-spheres 2026-05-10 08:42:22 -07:00
Jose Duarte
e2e26c7e9c Updating changelog 2026-05-09 22:28:38 -07:00
Jose Duarte
5ca9020cbf mol-model: fix water leak in surroundingLigands query
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:21:49 -07:00
Jose Duarte
ea4c411d5c model-server: fix omit_water boolean parsing for REST GET requests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:21:49 -07:00
Alexander Rose
ba7e3fe827 Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/1822 2026-05-09 16:03:21 -07:00
Alexander Rose
8f20571a17 Merge pull request #1827 from molstar/camera-changed-event
Camera helpers
2026-05-09 16:02:30 -07:00
Alexander Rose
c25a4247e6 Merge branch 'master' of https://github.com/molstar/molstar into camera-changed-event 2026-05-09 15:57:47 -07:00
Alexander Rose
1071d3d8ba Merge pull request #1828 from molstar/instance-granularity-improvements
Instance granularity improvements
2026-05-09 15:56:36 -07:00
Alexander Rose
e8dc046570 Merge branch 'master' of https://github.com/molstar/molstar into instance-granularity-improvements 2026-05-09 15:54:00 -07:00
Alexander Rose
27f9c2aa67 Merge pull request #1829 from molstar/mesoscale-preset
Mesoscale preset
2026-05-09 15:53:29 -07:00
Alexander Rose
a4962231c8 revert 2026-05-09 15:51:05 -07:00
Alexander Rose
8833f29ce5 Merge branch 'master' of https://github.com/molstar/molstar into mesoscale-preset 2026-05-09 15:44:01 -07:00
Alexander Rose
40b6038380 type tweak 2026-05-09 15:43:04 -07:00
Armando Pellegrini
59e16e0187 Fix State.dispose() not invoking transformer dispose for live cells (#1826)
`Transformer.Definition.dispose` is documented as "automatically called
on deleting an object," but `State.dispose()` only disposed its own event
subjects and action manager — it never iterated still-live cells to call
their per-transformer dispose. Cells holding GL buffers, mesh data, etc.
only had their dispose fired on explicit deletion (e.g. `clear()`), so
any consumer that called `plugin.dispose()` without first awaiting
`plugin.clear()` retained the callback chain, the GL buffers it points
at, and any closures captured by it.

In a long-running single-page app where the user navigates between
routes that mount/unmount a Mol* viewer, this leaked roughly 25–50 MB
of process RSS per cycle even with `plugin.dispose()` correctly called.
A 20-cycle E2E mount/unmount harness on a 1AKE structure measured a
+541 MB RSS / +266 MB JS-heap delta in the unconditional-`dispose()`
case; calling `await plugin.clear()` before `plugin.dispose()` halved
the residual leak, confirming the per-cell dispose path was missing on
the unconditional `dispose()` route.

This change walks the cell tree once (post-order via the existing
`StateTree.doPostOrder` helper) and invokes the per-transformer dispose
for every still-live cell, swallowing+warning on errors so a single
faulty transformer can't prevent siblings from cleaning up. The
existing per-cell `dispose` helper is reused for consistency with
`updateNode`/`findDeletes` semantics.

Tests cover: chained transformers, sibling subtrees, throwing-dispose
isolation, and transformers without a dispose definition.

Also adds `useDefineForClassFields: false` to the jest esbuild
transform so tests can construct `State` (the `TransientTree` parameter
property + class field pattern relies on legacy class-field semantics,
which `tsc` honors via `target: es2018` but esbuild's default `esnext`
target does not).

Fixes #1825

Co-authored-by: Armando Pellegrini <tech.tools@boltz.bio>
2026-05-09 22:17:38 +02:00
Alexander Rose
ca5a50bd53 changelog 2026-05-09 12:36:38 -07:00
Alexander Rose
bccf54fabe avoid extra allocations 2026-05-09 12:36:32 -07:00
Alexander Rose
57a790544c Add mesoscale representation preset 2026-05-09 12:31:26 -07:00
Alexander Rose
df0669598c Add presets option to ObjectList param definition 2026-05-09 12:31:11 -07:00
Alexander Rose
fb912036af Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/1822 2026-05-09 08:25:26 -07:00
Alexander Rose
9efb5cd126 Add Camera.changed event and rotation/translation setter/getter 2026-05-09 08:24:26 -07:00
Alexander Rose
08a56ad6ab Instance granularity improvements
- Add `instanceGranularity: 'auto'` as a memory guard
- Honor `instanceGranularity` in `Visual.getLoci`
2026-05-09 08:10:58 -07:00
Sebastian
2c2bd6adda tweak wb labels 2026-05-06 16:06:33 +02:00
Sebastian
b010298acb fix merge 2026-05-06 15:27:45 +02:00
Sebastian
7033a1e0b2 Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	CHANGELOG.md
2026-05-06 15:23:35 +02:00
Sebastian
8ad617acdf fix refinement 2026-05-06 15:20:34 +02:00
Sebastian
31ab6aa93e iterator improv 2026-05-06 11:32:50 +02:00
Sebastian
0a2dbe14d7 refine wb impl/vis 2026-05-06 11:11:43 +02:00
Sebastian
89d305aaa1 cl 2026-05-06 10:41:42 +02:00
Sebastian
dbb6b90fbc nci: improve wb visuals on shared legs 2026-05-06 10:37:08 +02:00
Sebastian
c57150f09f nci: filter hbonds if explained by water bridge 2026-05-06 10:22:33 +02:00
Sebastian
0b30c7344b nci: water bridge support 2026-05-06 09:56:06 +02:00
Alexander Rose
d7ad5a6e9f Fix empty transforms default in ShapeFromPly 2026-05-04 23:14:02 -07:00
Ludovic Autin
19fec3bbc1 Order DOT spheres by Morton index
Add DOT sphere impostors in Morton order so sphere LOD stride sampling remains spatially distributed.
2026-05-02 10:48:31 -07:00
67 changed files with 5029 additions and 208 deletions

View File

@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file, following t
Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here.
## [Unreleased]
- Fix exported image artifacts on transparent background with emissive, bloom, or antialiasing
- Fix cel-shaded ambient color being stripped to luminance (now uses full RGB, matching the classic lighting path)
- Fix empty transforms default in `ShapeFromPly`
- Use morton order for spheres in dot visual with lod-levels
- Add `Camera.changed` event and rotation/translation setter/getter
- Add `instanceGranularity: 'auto'` as a memory guard
- Honor `instanceGranularity` in `Visual.getLoci`
- Add mesoscale representation preset
- Add presets option to `ObjectList` param definition
- Fix memory leak in `State.dispose()` not invoking transformer `dispose` callbacks for live cells
- Adds File/Open and drag-and-drop support for Kinemage files in the viewer app
- Fix bugs in ModelServer surroundingLigands endpoint, resulting in omitWater not honored
- Fix `Volume` and `Isosurface` getBoundingSphere ignoring instances
- Fix aromatic ring detection not accounting for hybridization
- Add axis param to camera spin/rock animation
- Fix SSAO half/quarter resolution textures for multi-scale
- Non-covalent interactions: water bridge support
- Download Structure From AlphaFoldDB allows IDs with version suffix (version is ignored)
- Add `loadUrl` method and GET params to Viewer app
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

View File

@@ -74,7 +74,7 @@
"js"
],
"transform": {
"\\.ts$": "esbuild-jest-transform"
"\\.ts$": ["esbuild-jest-transform", { "tsconfigRaw": "{\"compilerOptions\":{\"useDefineForClassFields\":false}}" }]
},
"moduleDirectories": [
"node_modules",
@@ -125,7 +125,8 @@
"Kim Juho <juho_kim@outlook.com>",
"Victoria Doshchenko <doshchenko.victoria@gmail.com>",
"Diego del Alamo <diego.delalamo@gmail.com>",
"Tianzhen Lin (Tangent) <tangent@usa.net>"
"Tianzhen Lin (Tangent) <tangent@usa.net>",
"Russ Taylor <russ@reliasolve.com>"
],
"license": "MIT",
"devDependencies": {

View File

@@ -22,7 +22,6 @@ import { Hcl } from '../../../mol-util/color/spaces/hcl';
import { StateObjectCell, StateObjectRef, StateSelection } from '../../../mol-state';
import { ShapeRepresentation3D, StructureRepresentation3D } from '../../../mol-plugin-state/transforms/representation';
import { SpacefillRepresentationProvider } from '../../../mol-repr/structure/representation/spacefill';
import { assertUnreachable } from '../../../mol-util/type-helpers';
import { MesoscaleExplorerState } from '../app';
import { saturate } from '../../../mol-math/interpolate';
import { Material } from '../../../mol-util/material';
@@ -322,38 +321,7 @@ export function getMesoscaleGroupParams(graphicsMode: GraphicsMode): MesoscaleGr
export type LodLevels = typeof SpacefillRepresentationProvider.defaultValues['lodLevels']
export function getLodLevels(graphicsMode: Exclude<GraphicsMode, 'custom'>): LodLevels {
switch (graphicsMode) {
case 'performance':
return [
{ minDistance: 1, maxDistance: 300, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 300, maxDistance: 2000, overlap: 0, stride: 40, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 150, scaleBias: 3 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 300, scaleBias: 2.5 },
];
case 'balanced':
return [
{ minDistance: 1, maxDistance: 500, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 500, maxDistance: 2000, overlap: 0, stride: 15, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 70, scaleBias: 2.7 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.5 },
];
case 'quality':
return [
{ minDistance: 1, maxDistance: 1000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 1000, maxDistance: 4000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 4000, maxDistance: 10000, overlap: 0, stride: 50, scaleBias: 2.7 },
{ minDistance: 10000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.3 },
];
case 'ultra':
return [
{ minDistance: 1, maxDistance: 5000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 5000, maxDistance: 10000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 10000, maxDistance: 30000, overlap: 0, stride: 50, scaleBias: 2.5 },
{ minDistance: 30000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2 },
];
default:
assertUnreachable(graphicsMode);
}
return Spheres.LodLevelsPresets[graphicsMode];
}
export type GraphicsMode = 'ultra' | 'quality' | 'balanced' | 'performance' | 'custom';

View File

@@ -14,7 +14,7 @@ import { MVSData } from '../../extensions/mvs/mvs-data';
import { StringLike } from '../../mol-io/common/string-like';
import { Structure, StructureElement } from '../../mol-model/structure';
import { Volume } from '../../mol-model/volume';
import { OpenFiles } from '../../mol-plugin-state/actions/file';
import { DownloadFile, OpenFiles } from '../../mol-plugin-state/actions/file';
import { DownloadStructure, PdbDownloadProvider } from '../../mol-plugin-state/actions/structure';
import { DownloadDensity } from '../../mol-plugin-state/actions/volume';
import { PresetTrajectoryHierarchy } from '../../mol-plugin-state/builder/structure/hierarchy-preset';
@@ -523,6 +523,17 @@ export class Viewer {
}
}
loadUrl(url: string, format: string, isBinary = false) {
return this.plugin.runTask(Task.create('Load URL', async taskCtx => {
await this.plugin.state.data.applyAction(DownloadFile, {
url: Asset.Url(url),
format,
isBinary,
visuals: true
}).runInContext(taskCtx);
}));
}
handleResize() {
this.plugin.layout.events.updated.next(void 0);
}

View File

@@ -4,6 +4,7 @@
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Adam Midlik <midlik@gmail.com>
* @author Russ Taylor <russ@reliasolve.com>
*/
import { ANVILMembraneOrientation } from '../../extensions/anvil/behavior';
@@ -28,6 +29,7 @@ import { ZenodoImport } from '../../extensions/zenodo';
import { PluginSpec } from '../../mol-plugin/spec';
import { MVSData } from '../../extensions/mvs/mvs-data';
import * as MVSUtil from '../../extensions/mvs/util';
import { KinemageExtension } from '../../extensions/kinemage/behavior';
export const ExtensionMap = {
// Mol* built-in extensions
@@ -39,6 +41,7 @@ export const ExtensionMap = {
'geo-export': PluginSpec.Behavior(GeometryExport),
'zenodo-import': PluginSpec.Behavior(ZenodoImport),
'wwpdb-chemical-component-dictionary': PluginSpec.Behavior(wwPDBChemicalComponentDictionary),
'kinemage': PluginSpec.Behavior(KinemageExtension),
// 3rd party extensions
'pdbe-structure-quality-report': PluginSpec.Behavior(PDBeStructureQualityReport),

View File

@@ -129,6 +129,11 @@
var modelArchive = getParam('model-archive', '[^&]+').trim();
if (modelArchive) viewer.loadModelArchive(modelArchive);
var url = getParam('url', '[^&]+').trim();
var urlFormat = getParam('url-format', '[^&]+').trim() || undefined;
var urlIsBinary = getParam('url-is-binary', '[^&]+').trim() === '1';
if (url && urlFormat) viewer.loadUrl(url, urlFormat, urlIsBinary);
window.addEventListener('unload', () => {
// to aid GC
viewer.dispose();

View File

@@ -25,6 +25,7 @@ export type InteractionElementSchema =
| { kind: 'weak-hydrogen-bond' } & InteractionElementSchemaBase
| { kind: 'hydrophobic' } & InteractionElementSchemaBase
| { kind: 'metal-coordination' } & InteractionElementSchemaBase
| { kind: 'water-bridge' } & InteractionElementSchemaBase
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 } & InteractionElementSchemaBase
export type InteractionKind = InteractionElementSchema['kind']
@@ -39,6 +40,7 @@ export const InteractionKinds: InteractionKind[] = [
'weak-hydrogen-bond',
'hydrophobic',
'metal-coordination',
'water-bridge',
'covalent',
];
@@ -52,6 +54,7 @@ export type InteractionInfo =
| { kind: 'weak-hydrogen-bond', hydrogenStructureRef?: string, hydrogen?: StructureElement.Loci }
| { kind: 'hydrophobic' }
| { kind: 'metal-coordination' }
| { kind: 'water-bridge' }
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 }
export interface StructureInteractionElement {
@@ -80,4 +83,5 @@ export const InteractionTypeToKind = {
[InteractionType.Hydrophobic]: 'hydrophobic' as InteractionKind,
[InteractionType.MetalCoordination]: 'metal-coordination' as InteractionKind,
[InteractionType.WeakHydrogenBond]: 'weak-hydrogen-bond' as InteractionKind,
[InteractionType.WaterBridge]: 'water-bridge' as InteractionKind,
};

View File

@@ -47,6 +47,7 @@ export const InteractionVisualParams = {
'weak-hydrogen-bond': hydrogenVisualParams({ color: Color(0x0) }),
'hydrophobic': visualParams({ color: Color(0x555555) }),
'metal-coordination': visualParams({ color: Color(0x952e8f) }),
'water-bridge': visualParams({ color: Color(0x00CCEE), style: 'dashed' }),
'covalent': PD.Group({
color: PD.Color(Color(0x999999)),
radius: PD.Numeric(0.1, { min: 0.01, max: 1, step: 0.01 }),

View File

@@ -0,0 +1,28 @@
# Kinemage extension
This extension adds support for the Kinemage molecular graphics format based on the
[kinemage format specification](http://kinemage.biochem.duke.edu/static/files/PDFs/format-kinemage.pdf).
It currently supports the following features:
- Drag-and-drop of Kinemage files into the display area
- Open File can open Kinemage files from the local filesystem
- Display of @ball, @sphere, @vector, @dot, @ribbon, and @triangle lists
- Coloring of objects by vertex color, or by a single color for the entire list
- Hovering over objects to see their labels (if present)
- When there are views defined, controls are added to the right panel; when selected, they shift the view
- When the view is changes, the projection is set to orthographic and the background is set to black to match Kinemage's default view
- Control panel names are based on the @pdbfile or @caption in the Kinemage file if there is one
- Lines are split in half, with each half colored by and labeled by the nearest vertex
- Master and submaster selections of visible objects
- Group and subgroup hierarchy with buttons to control visibility
- @pointmaster lists controlling visibility of points
- animate/2animate: First entry turned on to start, changing visibility of Animate button cycles through them
Currently unsupported features include:
- @label and @ring lists
- @hsvcolor keyword for coloring by hue, saturation, and value
- 'fore' and 'rear' keywords for different front and back colors
Current limitations include:
- Triangles are a single color, not colored by vertex (Mol* does not support per-vertex coloring for these primitives)
- Line segments in Mol* do not support end-caps for wide lines, so there are artifacts in highly-curved lines

View File

@@ -0,0 +1,188 @@
/**
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { parseKin } from '../reader/parser';
const kinString = `@kinemage 1
@caption probe.2.26.021123, run Tue Apr 23 14:49:17 2024
command: C:\tmp\cctbx_phenix\build\probe\exe\probe.exe -kin -mc -het -once -wat2wat -onlybadout -stdbonds water all 1ssxFH.pdb
@group dominant {dots}
@subgroup dominant {once dots}
@master {bad overlap}
@pointmaster 'O' {Hets contacts}
@vectorlist {x} color=red master={bad overlap}
{ O HOH 319 A}hotpink P 'O' 31.146,32.100,-1.425 {"}hotpink 'O' 31.015,32.234,-1.324
{"}hotpink P 'O' 31.607,32.750,-1.156 {"}hotpink 'O' 31.410,32.784,-1.097
{"}hotpink P 'O' 31.263,32.074,-1.185 {"}hotpink 'O' 31.117,32.209,-1.122
{ O BHOH 338 A}hotpink P 'O' 32.540,45.631,10.833 {"}hotpink 'O' 32.430,45.771,10.977
{"}hotpink P 'O' 32.316,45.500,10.828 {"}hotpink 'O' 32.230,45.689,10.998
{"}hotpink P 'O' 32.068,45.424,10.824 {"}hotpink 'O' 32.034,45.604,10.975
{"}hotpink P 'O' 32.729,45.605,11.052 {"}hotpink 'O' 32.572,45.765,11.173
`;
// Complex kinemage with multiple features: animate groups, pointmasters, various list types
const kinComplexString = `@kinemage 1
@caption Complex test kinemage with multiple features
@text
This is a comprehensive test kinemage file that includes:
- Multiple groups with animate and 2animate
- Pointmasters with tags
- All list types: dots, vectors, balls, spheres, ribbons, triangles
@master {main} on
@master {secondary} off
@master {alternate}
@pointmaster 'ABC' {Primary atoms} on
@pointmaster 'XY' {Secondary atoms} off
@group {Structure} animate dominant
@subgroup {Backbone}
@vectorlist {CA trace} color=blue master={main}
{CA ALA 1}blue P 'A' 10.0,20.0,30.0 {CA ALA 2}blue 'A' 11.0,21.0,31.0
{"}blue 'A' 12.0,22.0,32.0 {CA ALA 3}blue 'A' 13.0,23.0,33.0
@dotlist {H-bonds} color=yellow master={main}
{HN ALA 2}yellow 'B' 10.5,20.5,30.5
{HN ALA 3}yellow 'B' 11.5,21.5,31.5
{HN ALA 4}yellow 'B' 12.5,22.5,32.5
@subgroup {Sidechains}
@balllist {CB atoms} color=green master={secondary} radius=0.5
{CB ARG 1}green r=0.5 'C' 9.0,19.0,29.0
{CB ARG 2}green r=0.6 'C' 10.0,20.0,30.0
{CB ARG 3}green r=0.55 'C' 11.0,21.0,31.0
@group {Alternate conformations} 2animate
@subgroup {Alt A}
@spherelist {Waters A} color=cyan master={alternate} radius=1.0
{HOH 101}cyan r=1.0 'X' 15.0,25.0,35.0
{HOH 102}cyan r=1.2 'X' 16.0,26.0,36.0
@subgroup {Alt B}
@spherelist {Waters B} color=magenta master={alternate} radius=1.0
{HOH 101}magenta r=1.0 'Y' 15.2,25.2,35.2
{HOH 102}magenta r=1.1 'Y' 16.1,26.1,36.1
@group {Surface} off
@subgroup {Ribbons}
@ribbonlist {Alpha helix} color=red master={main}
{ASP 5}red 14.0,24.0,34.0
{GLU 6}red 15.0,25.0,35.0
{LYS 7}red 16.0,26.0,36.0
{ARG 8}red 17.0,27.0,37.0
{THR 9}red P 18.0,28.0,38.0
{VAL 10}red 19.0,29.0,39.0
@subgroup {Triangles}
@trianglelist {Surface patch} color=sky master={secondary}
{Tri 1}sky 20.0,30.0,40.0
{Tri 1}sky 21.0,30.0,40.0
{Tri 1}sky 20.5,31.0,40.0
{Tri 2}sky X 22.0,32.0,42.0
{Tri 2}sky 23.0,32.0,42.0
{Tri 2}sky 22.5,33.0,42.0
@group {Contacts} animate
@subgroup {Clashes}
@vectorlist {Bad overlaps} color=hotpink master={main} width=4
{O HOH 319 A}hotpink P 31.146,32.100,-1.425 {O HOH 320 A}hotpink 31.015,32.234,-1.324
{"}hotpink P 31.607,32.750,-1.156 {"}hotpink 31.410,32.784,-1.097
`;
describe('kin reader', () => {
it('basic', async () => {
const parsed = await parseKin(kinString).run();
if (parsed.isError) {
console.error('Parse error:', parsed);
fail('Parse should not error');
}
if (parsed.result.length !== 1) {
fail(`Expected 1 kinemage, got ${parsed.result.length}`);
}
const kinemage = parsed.result[0];
const vectors = kinemage.vectorLists;
expect(vectors.length).toEqual(1);
const element = vectors[0];
expect(element.name).toEqual('x');
expect(element.position1Array.length).toEqual(7*3);
// Test that colors are parsed correctly
expect(element.color1Array.length).toEqual(7);
// Test masters are set up
expect(element.masterArray).toContain('bad overlap');
expect.assertions(5);
});
it('complex', async () => {
const parsed = await parseKin(kinComplexString).run();
if (parsed.isError) {
fail('Parse should not error');
}
expect(parsed.result.length).toBeGreaterThan(0);
const kinemage = parsed.result[0];
// Verify structure is valid
expect(kinemage.vectorLists).toBeDefined();
expect(kinemage.masterDict).toBeDefined();
expect(kinemage.groupDict).toBeDefined();
expect(kinemage.pointmasterDict).toBeDefined();
// Test animate groups
expect(kinemage.groupsAnimate.length).toEqual(2);
expect(kinemage.groupsAnimate).toContain('Structure');
expect(kinemage.groupsAnimate).toContain('Contacts');
expect(kinemage.activeAnimateGroup).toEqual(0);
// Test 2animate groups
expect(kinemage.groupsAnimate2.length).toEqual(1);
expect(kinemage.groupsAnimate2).toContain('Alternate conformations');
expect(kinemage.activeAnimateGroup2).toEqual(0);
// Test pointmasters
expect(Object.keys(kinemage.pointmasterDict).length).toBeGreaterThan(0);
expect(kinemage.pointmasterDict['A']).toEqual('Primary atoms');
expect(kinemage.pointmasterDict['B']).toEqual('Primary atoms');
expect(kinemage.pointmasterDict['X']).toEqual('Secondary atoms');
// Test masters
expect(kinemage.masterDict['main']).toBeDefined();
expect(kinemage.masterDict['main'].visible).toEqual(true);
expect(kinemage.masterDict['secondary']).toBeDefined();
expect(kinemage.masterDict['secondary'].visible).toEqual(false);
// Test list types
expect(kinemage.vectorLists.length).toEqual(2);
expect(kinemage.dotLists.length).toEqual(1);
expect(kinemage.ballLists.length).toEqual(3); // 1 balllist + 2 spherelists
expect(kinemage.ribbonLists.length).toEqual(2); // 1 ribbonlist + 1 trianglelist
// Test specific list properties
const caTrace = kinemage.vectorLists.find(v => v.name === 'CA trace');
expect(caTrace).toBeDefined();
expect(caTrace?.masterArray).toContain('main');
const hBonds = kinemage.dotLists[0];
expect(hBonds.name).toEqual('H-bonds');
expect(hBonds.positionArray.length).toEqual(9); // 3 dots * 3 coords
const cbAtoms = kinemage.ballLists.find(b => b.name === 'CB atoms');
expect(cbAtoms).toBeDefined();
expect(cbAtoms?.radiusArray.length).toEqual(3);
const helix = kinemage.ribbonLists.find(r => r.name === 'Alpha helix');
expect(helix).toBeDefined();
expect(helix?.pairTriangleNormals).toEqual(true); // ribbonlist
const surface = kinemage.ribbonLists.find(r => r.name === 'Surface patch');
expect(surface).toBeDefined();
expect(surface?.pairTriangleNormals).toEqual(false); // trianglelist
// Test groups
expect(Object.keys(kinemage.groupDict).length).toEqual(4);
expect(kinemage.groupDict['Structure'].animate).toEqual(true);
expect(kinemage.groupDict['Alternate conformations']['2animate']).toEqual(true);
expect(kinemage.groupDict['Surface'].off).toEqual(true);
expect.assertions(38);
});
});

View File

@@ -0,0 +1,663 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { Vec3, Mat3 } from '../../mol-math/linear-algebra';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { KinemageDataProvider, KinemageData } from './prop';
import { StateTransformer, StateBuilder } from '../../mol-state';
import { Task } from '../../mol-task';
import { PluginBehavior } from '../../mol-plugin/behavior';
import { PluginDragAndDropHandler } from '../../mol-plugin-state/manager/drag-and-drop';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { PluginContext } from '../../mol-plugin/context';
import { DefaultQueryRuntimeTable } from '../../mol-script/runtime/query/compiler';
import { StateTransforms } from '../../mol-plugin-state/transforms';
import { shapePointsFromKin, shapeLinesFromKin, shapeMeshFromKin, shapeSpheresFromKin } from './kin';
import { Kinemage } from './reader/schema';
import { DataFormatProvider } from '../../mol-plugin-state/formats/provider';
import { Camera } from '../../mol-canvas3d/camera';
import { PluginCommands } from '../../mol-plugin/commands';
import { getPluginBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object';
import { KinemageControls } from './ui';
import { StateObjectSelector } from '../../mol-state';
import { Color } from '../../mol-util/color';
const Tag = KinemageData.Tag;
const Transform = StateTransformer.builderFactory('sb-kinemage');
/**
* State object to hold parsed Kinemage data
*/
export class KinemageObject extends PluginStateObject.Create<KinemageData>({ name: 'Kinemage', typeClass: 'Object' }) { }
/**
* Visibility state for kinemage elements - stores which items are VISIBLE (not hidden)
*/
export interface KinemageVisibilityState {
/** Map of group name -> visibility (true = visible, false = hidden/off) */
groupVisibility: Map<string, boolean>;
/** Map of subgroup name -> visibility (true = visible, false = hidden/off) */
subgroupVisibility: Map<string, boolean>;
/** Map of master name -> visibility (true = visible, false = hidden) */
masterVisibility: Map<string, boolean>;
activeAnimateGroup: number;
activeAnimateGroup2: number;
}
/**
* Apply a saved snapshot object (from a view state node) to the plugin camera.
* Use PluginCommands.Camera.SetSnapshot so transitions and canvas props are handled properly.
*/
export async function applyViewSnapshot(plugin: PluginContext, snapshot: Partial<Camera.Snapshot>) {
if (!snapshot) return;
// Set background color to black
plugin.canvas3d?.setProps({
renderer: {
...plugin.canvas3d.props.renderer,
backgroundColor: Color(0x000000)
}
});
// If the snapshot provides a target, adjust the canvas `sceneRadiusFactor` so the scene isn't clipped
// when we switch camera.
if (snapshot.target) {
try {
const boundingSphere = getPluginBoundingSphere(plugin);
if (boundingSphere && boundingSphere.radius > 0) {
const offset = Vec3.distance(snapshot.target as Vec3, boundingSphere.center);
const sceneRadiusFactor = (boundingSphere.radius + offset) / boundingSphere.radius;
plugin.canvas3d?.setProps({ sceneRadiusFactor });
}
} catch (e) {
// fallback: ignore errors and continue to set the camera snapshot
console.warn('Failed to adjust sceneRadiusFactor for view snapshot', e);
}
}
await PluginCommands.Camera.SetSnapshot(plugin, { snapshot });
}
/**
* Transform to parse Kinemage data from string/data input
*/
export const ParseKinemage = Transform({
name: 'sb-kinemage-parse',
display: { name: 'Parse Kinemage' },
from: [PluginStateObject.Data.String],
to: KinemageObject,
params: {
label: PD.Optional(PD.Text('', { description: 'Label for the Kinemage data' }))
}
})({
apply({ a, params }) {
return Task.create('Parse Kinemage', async ctx => {
const input = a.data;
let data: KinemageData;
if (typeof input === 'string') {
// Parse from string content
const file = new File([input], 'input.kin', { type: 'text/plain' });
data = await KinemageData.open(file);
} else {
throw new Error('Unsupported input type for ParseKinemage');
}
// Precompute camera snapshots for all views in all kinemages
for (const kinData of data.kinemages) {
(kinData as any).viewSnapshots = (kinData as any).viewSnapshots || Object.create(null);
for (const [viewKey, viewObj] of Object.entries(kinData.viewDict)) {
const center = Vec3.create(0, 0, 0);
if (viewObj.center) {
Vec3.set(center, viewObj.center[0], viewObj.center[1], viewObj.center[2]);
}
const orientation: Mat3 = Mat3.identity();
if (viewObj.matrix) {
Mat3.fromArray(orientation, viewObj.matrix, 0);
Mat3.transpose(orientation, orientation);
}
const zAxis = Vec3.create(0, 0, 1);
Vec3.transformMat3(zAxis, zAxis, orientation);
const yAxis = Vec3.create(0, 1, 0);
Vec3.transformMat3(yAxis, yAxis, orientation);
let distance = 100;
if (viewObj.span) {
distance = viewObj.span;
}
Vec3.scale(zAxis, zAxis, distance);
const position = Vec3.create(0, 0, 100);
Vec3.add(position, center, zAxis);
let radius = 100;
if (viewObj.zslab) {
const scale = viewObj.zslab / 200;
radius = 0.5 * distance * scale;
}
const snap: Camera.Snapshot = {
mode: 'orthographic',
fov: Math.PI / 4,
position,
up: yAxis,
target: center,
radius,
radiusMax: 1e4,
fog: 0,
clipFar: true,
minNear: 1,
minFar: 1
};
(kinData as any).viewSnapshots[viewKey] = snap;
}
}
const label = params.label || data.kinemages[0]?.caption || 'Kinemage';
return new KinemageObject(data, { label, description: `Kinemage with ${data.kinemages.length} kinemage(s)` });
});
}
});
/**
* Transform to select a specific kinemage from parsed data
*/
export const SelectKinemage = Transform({
name: 'sb-kinemage-select',
display: { name: 'Select Kinemage' },
from: KinemageObject,
to: PluginStateObject.Format.Json,
params: (a) => {
const kinemages = a?.data?.kinemages || [];
const options = kinemages.map((k: Kinemage, i: number) => [i, k.pdbfile || k.caption || `Kinemage ${i}`] as const);
return {
index: PD.Select(0, options, { description: 'Which kinemage to use' })
};
}
})({
apply({ a, params }) {
return Task.create('Select Kinemage', async ctx => {
const kinData = a.data.kinemages[params.index];
if (!kinData) {
throw new Error(`No kinemage found at index ${params.index}`);
}
const label = kinData.pdbfile || kinData.caption || `Kinemage ${params.index}`;
// Store the kinemage data in a Format.Json node so downstream transforms can access it
return new PluginStateObject.Format.Json(
{ kinData },
{ label, description: kinData.text || '' }
);
});
}
});
/**
* Visibility Controller Transform - centralizes visibility state for all shape types
* Stores visibility as key-value pairs where key is the item name and value is boolean (true = visible)
*/
export const KinemageVisibilityController = Transform({
name: 'sb-kinemage-visibility-controller',
display: { name: 'Kinemage Visibility Controller' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Format.Json,
params: (a) => {
const kinData = (a?.data as any)?.kinData as Kinemage | undefined;
if (!kinData) {
return {
groupVisibility: PD.Value<{ [key: string]: boolean }>({}),
subgroupVisibility: PD.Value<{ [key: string]: boolean }>({}),
masterVisibility: PD.Value<{ [key: string]: boolean }>({}),
activeAnimateGroup: PD.Numeric(0, { min: 0, max: 0, step: 1 }, { description: 'Active animate group index' }),
activeAnimateGroup2: PD.Numeric(0, { min: 0, max: 0, step: 1 }, { description: 'Active animate2 group index' })
};
}
// Build initial visibility from parsed data
const groupVisibility: { [key: string]: boolean } = {};
const subgroupVisibility: { [key: string]: boolean } = {};
const masterVisibility: { [key: string]: boolean } = {};
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict)) {
groupVisibility[groupKey] = !(groupInfo as any).off;
}
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict)) {
subgroupVisibility[subgroupKey] = !(subgroupInfo as any).off;
}
for (const [masterKey, masterInfo] of Object.entries(kinData.masterDict)) {
masterVisibility[masterKey] = !!(masterInfo as any).visible;
}
return {
groupVisibility: PD.Value(groupVisibility, { isHidden: true }),
subgroupVisibility: PD.Value(subgroupVisibility, { isHidden: true }),
masterVisibility: PD.Value(masterVisibility, { isHidden: true }),
activeAnimateGroup: PD.Numeric(0, { min: 0, max: Math.max(0, kinData.groupsAnimate.length - 1), step: 1 }, { description: 'Active animate group index', isHidden: true }),
activeAnimateGroup2: PD.Numeric(0, { min: 0, max: Math.max(0, kinData.groupsAnimate2.length - 1), step: 1 }, { description: 'Active animate2 group index', isHidden: true })
};
}
})({
apply({ a, params }) {
return Task.create('Kinemage Visibility Controller', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
// Store visibility state alongside kinData
const visibilityState: KinemageVisibilityState = {
groupVisibility: new Map(Object.entries(params.groupVisibility)),
subgroupVisibility: new Map(Object.entries(params.subgroupVisibility)),
masterVisibility: new Map(Object.entries(params.masterVisibility)),
activeAnimateGroup: params.activeAnimateGroup,
activeAnimateGroup2: params.activeAnimateGroup2
};
return new PluginStateObject.Format.Json(
{ kinData, visibilityState },
{ label: a.label, description: a.description }
);
});
}
});
export const KinemageShapePointsProvider = Transform({
name: 'sb-kinemage-shape-points-provider',
display: { name: 'Kinemage Shape Points Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Points Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapePointsFromKin(kinData, visibilityState, { transforms: undefined }, 'Dots').runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Points',
description: kinData.text || ''
});
});
}
});
export const KinemageShapeLinesProvider = Transform({
name: 'sb-kinemage-shape-lines-provider',
display: { name: 'Kinemage Shape Lines Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Lines Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapeLinesFromKin(kinData, visibilityState).runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Lines',
description: kinData.text || ''
});
});
}
});
export const KinemageShapeMeshProvider = Transform({
name: 'sb-kinemage-shape-mesh-provider',
display: { name: 'Kinemage Shape Mesh Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Mesh Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapeMeshFromKin(kinData, visibilityState).runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Meshes',
description: kinData.text || ''
});
});
}
});
export const KinemageShapeSpheresProvider = Transform({
name: 'sb-kinemage-shape-spheres-provider',
display: { name: 'Kinemage Shape Spheres Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Spheres Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapeSpheresFromKin(kinData, visibilityState).runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Spheres',
description: kinData.text || ''
});
});
}
});
export const KinemageExtension = PluginBehavior.create<{ autoAttach: boolean }>({
name: 'kinemage-data-prop',
category: 'custom-props',
display: {
name: 'Kinemage data',
description: 'Data loaded from Kinemage.'
},
ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
private provider = KinemageDataProvider;
register(): void {
DefaultQueryRuntimeTable.addCustomProp(this.provider.descriptor);
this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
// Register right-panel controls for Kinemage (show in the right-hand inspector)
this.ctx.customStructureControls.set(Tag.Representation, KinemageControls as any);
// Some app hosts expose a global customControls registry; register there too so the card is visible
// even when no structure is loaded. Use `any` guards to avoid type errors if customControls isn't present.
if ((this.ctx as any).customControls && typeof (this.ctx as any).customControls.set === 'function') {
(this.ctx as any).customControls.set('kinemage', KinemageControls as any);
}
this.ctx.managers.dragAndDrop.addHandler(KinemageDragAndDropHandler.name, KinemageDragAndDropHandler.handle);
// Register .kin file handler so opening/dropping .kin is supported via the data formats system
this.ctx.dataFormats.add('KIN', KINFormatProvider);
}
update(p: { autoAttach: boolean }) {
const updated = this.params.autoAttach !== p.autoAttach;
this.params.autoAttach = p.autoAttach;
this.ctx.customStructureProperties.setDefaultAutoAttach(this.provider.descriptor.name, this.params.autoAttach);
return updated;
}
unregister() {
DefaultQueryRuntimeTable.removeCustomProp(this.provider.descriptor);
this.ctx.customStructureProperties.unregister(this.provider.descriptor.name);
this.ctx.genericRepresentationControls.delete(Tag.Representation);
this.ctx.managers.dragAndDrop.removeHandler(KinemageDragAndDropHandler.name);
// Unregister the .kin data format provider
this.ctx.dataFormats.remove('KIN');
// Remove right-panel controls
try { this.ctx.customStructureControls.delete(Tag.Representation); } catch { }
if ((this.ctx as any).customControls && typeof (this.ctx as any).customControls.delete === 'function') {
try { (this.ctx as any).customControls.delete('kinemage'); } catch { }
}
}
},
params: () => ({
autoAttach: PD.Boolean(false)
})
});
/** Registerable method for handling dragged-and-dropped files */
interface DragAndDropHandler {
name: string,
handle: PluginDragAndDropHandler,
}
/** Helper function to create all shapes for a kinemage via proper transform chain */
async function createShapesForKinemage(plugin: PluginContext, update: StateBuilder.Root, visControllerSelector: StateObjectSelector<PluginStateObject.Format.Json>) {
const visControllerCell = plugin.state.data.cells.get(visControllerSelector.ref);
if (!visControllerCell?.obj?.data) return;
const kinData = (visControllerCell.obj.data as any).kinData as Kinemage;
if (!kinData) return;
// Generate all shape types that have data, each as child of the visibility controller
if (kinData.dotLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.vectorLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.ribbonLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true });
}
if (kinData.ballLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
}
/** Centralized helper to apply kinemage content into plugin state */
async function applyKinemageToState(plugin: PluginContext, data: string, label?: string) {
const update = plugin.state.data.build();
// Create String data node
const dataNode = update
.toRoot()
.apply(StateTransforms.Data.RawData, { data, label: label || 'Kinemage File' });
// Parse into KinemageObject
const parsedNode = dataNode
.apply(ParseKinemage, { label });
await update.commit();
// Get the parsed kinemage object to see how many kinemages it contains
const parsedCell = plugin.state.data.cells.get(parsedNode.ref);
const kinemageData = parsedCell?.obj?.data as KinemageData | undefined;
if (!kinemageData || !kinemageData.kinemages || kinemageData.kinemages.length === 0) {
console.warn('No kinemages found in parsed data');
return undefined;
}
// Create a separate visibility controller and shapes for EACH kinemage
const visControllerSelectors: StateObjectSelector<PluginStateObject.Format.Json>[] = [];
for (let i = 0; i < kinemageData.kinemages.length; i++) {
const kinUpdate = plugin.state.data.build();
// Select this specific kinemage
const selectedNode = kinUpdate
.to(parsedNode.ref)
.apply(SelectKinemage, { index: i });
// Add visibility controller for this kinemage
const visControllerNode = selectedNode
.apply(KinemageVisibilityController, {});
await kinUpdate.commit();
visControllerSelectors.push(visControllerNode.selector);
}
// Now create shapes for all kinemages
const shapeUpdate = plugin.state.data.build();
for (const visControllerSelector of visControllerSelectors) {
await createShapesForKinemage(plugin, shapeUpdate, visControllerSelector);
}
await shapeUpdate.commit();
// Wait for bounding sphere and focus camera
async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const bs = getPluginBoundingSphere(plugin);
if (bs && bs.radius > 0) return bs;
} catch { /* ignore */ }
await new Promise<void>(r => setTimeout(r, pollMs));
}
return null;
}
try {
const bs = await waitForNonEmptyBoundingSphere(plugin);
if (bs && bs.radius > 0 && plugin.canvas3d) {
await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 });
plugin.canvas3d?.commit();
}
} catch (e) {
console.warn('Failed to apply initial kinemage view snapshot', e);
}
return visControllerSelectors[0]; // Return first for backward compatibility
}
/** Programmatic loader: load a single File (a .kin) into the plugin state.
* Returns the ref to the first visibility controller node.
*/
export async function loadKinemageFile(plugin: PluginContext, file: File): Promise<StateObjectSelector<PluginStateObject.Format.Json> | undefined> {
const content = await file.text();
return await applyKinemageToState(plugin, content, file.name);
}
/** DragAndDropHandler handler for `.kin` files */
const KinemageDragAndDropHandler: DragAndDropHandler = {
name: 'kin',
async handle(files: File[], plugin: PluginContext): Promise<boolean> {
let applied = false;
for (const file of files) {
if (file.name.toLowerCase().endsWith('.kin')) {
const ref = await loadKinemageFile(plugin, file);
applied = applied || !!ref;
}
}
return applied;
},
};
const KINFormatProvider: DataFormatProvider<{}, any, any> = DataFormatProvider({
label: 'KIN',
description: 'Kinemage',
category: 'Miscellaneous',
stringExtensions: ['kin', 'KIN'],
parse: async (plugin, data) => {
try {
// data is already a StateObjectRef to the raw data in the tree
// Build the transform chain from it
const builder = plugin.state.data.build()
.to(data)
.apply(ParseKinemage, {});
await builder.commit();
// Get the parsed data to see how many kinemages
const parsedRef = builder.selector.ref;
const parsedCell = plugin.state.data.cells.get(parsedRef);
const kinemageData = parsedCell?.obj?.data as KinemageData | undefined;
if (!kinemageData || !kinemageData.kinemages || kinemageData.kinemages.length === 0) {
console.warn('No kinemages found in parsed data');
return {};
}
// Create visibility controllers for all kinemages
const visControllers: StateObjectSelector<PluginStateObject.Format.Json>[] = [];
for (let i = 0; i < kinemageData.kinemages.length; i++) {
const kinBuilder = plugin.state.data.build();
const selectedKin = kinBuilder
.to(parsedRef)
.apply(SelectKinemage, { index: i });
const visController = selectedKin
.apply(KinemageVisibilityController, {});
await kinBuilder.commit();
visControllers.push(visController.selector);
}
// Return all visibility controllers
return { visControllers };
} catch (e) {
console.error('Failed to parse KIN file', e);
throw e;
}
},
visuals: async (plugin, data) => {
if (!data?.visControllers || !Array.isArray(data.visControllers)) {
console.warn('[Kinemage] visuals: no visControllers array provided');
return;
}
// Create shapes for all kinemages
const shapeBuilder = plugin.state.data.build();
for (const visController of data.visControllers) {
await createShapesForKinemage(plugin, shapeBuilder, visController);
}
await shapeBuilder.commit();
// Wait for bounding sphere and focus camera
async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const bs = getPluginBoundingSphere(plugin);
if (bs && bs.radius > 0) return bs;
} catch { /* ignore */ }
await new Promise<void>(r => setTimeout(r, pollMs));
}
return null;
}
try {
const bs = await waitForNonEmptyBoundingSphere(plugin);
if (bs && bs.radius > 0 && plugin.canvas3d) {
await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 });
plugin.canvas3d?.commit();
}
} catch (e) {
console.warn('Failed to focus camera on kinemage', e);
}
return undefined;
}
});

View File

@@ -0,0 +1,656 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { RuntimeContext, Task } from '../../mol-task';
import { ShapeProvider } from '../../mol-model/shape/provider';
import { Color } from '../../mol-util/color';
import { Kinemage, DotList, VectorList, RibbonObject, BallList } from './reader/schema';
import { Lines } from '../../mol-geo/geometry/lines/lines';
import { LinesBuilder } from '../../mol-geo/geometry/lines/lines-builder';
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { Points } from '../../mol-geo/geometry/points/points';
import { PointsBuilder } from '../../mol-geo/geometry/points/points-builder';
import { Vec3 } from '../../mol-math/linear-algebra';
import { Spheres } from '../../mol-geo/geometry/spheres/spheres';
import { SpheresBuilder } from '../../mol-geo/geometry/spheres/spheres-builder';
import { Shape } from '../../mol-model/shape';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
import { KinemageVisibilityState } from './behavior';
export type KinData = {
source: Kinemage,
transforms?: Mat4[],
}
function createKinShapePointsParams(kinemage?: Kinemage) {
return {
...Points.Params,
};
}
export const KinShapePointsParams = createKinShapePointsParams();
export type KinShapePointsParams = typeof KinShapePointsParams
function createKinShapeLinesParams(kinemage?: Kinemage) {
return {
...Lines.Params,
};
}
export const KinShapeLinesParams = createKinShapeLinesParams();
export type KinShapeLinesParams = typeof KinShapeLinesParams
function createKinShapeMeshParams(kinemage?: Kinemage) {
return {
...Mesh.Params,
};
}
export const KinShapeMeshParams = createKinShapeMeshParams();
export type KinShapeMeshParams = typeof KinShapeMeshParams
function createKinShapeSpheresParams(kinemage?: Kinemage) {
return {
...Spheres.Params,
};
}
export const KinShapeSpheresParams = createKinShapeSpheresParams();
export type KinShapeSpheresParams = typeof KinShapeSpheresParams;
/**
* Check visibility using AND logic:
* - ALL masters must be visible
* - AND group must be visible
* - AND subgroup must be visible (and its parent group if it has one)
*/
function getVisibility(group: string, subGroup: string, masters: string[], kin: Kinemage, visibilityState?: KinemageVisibilityState) {
// If no visibility state provided, fall back to checking the original parsed data
if (!visibilityState) {
let visible = true;
// Check masters from parsed data
for (let m = 0; m < masters.length; m++) {
const masterName = masters[m];
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
visible = false;
break;
}
}
// Check group from parsed data
const groupInfo = kin.groupDict[group];
if (groupInfo && (groupInfo as any).off) {
visible = false;
}
// Check subgroup from parsed data
const subgroupInfo = kin.subgroupDict[subGroup];
if (subgroupInfo) {
if ((subgroupInfo as any).off) {
visible = false;
}
if ((subgroupInfo as any).group) {
const parentGroupInfo = kin.groupDict[(subgroupInfo as any).group];
if (parentGroupInfo && (parentGroupInfo as any).off) {
visible = false;
}
}
}
return visible;
}
// Use visibility state - all conditions must be true (AND logic)
// Check all masters - if ANY master is not visible, return false
for (let m = 0; m < masters.length; m++) {
const masterName = masters[m];
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
return false;
}
}
// Check group visibility
const groupVisible = visibilityState.groupVisibility.get(group);
if (groupVisible === false) {
return false;
}
// Check subgroup visibility
if (subGroup) {
const subgroupVisible = visibilityState.subgroupVisibility.get(subGroup);
if (subgroupVisible === false) {
return false;
}
// Also check if subgroup's parent group is visible
const subgroupInfo = kin.subgroupDict[subGroup];
if (subgroupInfo && (subgroupInfo as any).group) {
const parentGroupVisible = visibilityState.groupVisibility.get((subgroupInfo as any).group);
if (parentGroupVisible === false) {
return false;
}
}
}
return true;
}
async function getPoints(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const dotLists: DotList[] = kin.dotLists;
const builderState = PointsBuilder.create();
const colors: Color[] = [];
const labels: string[] = [];
// Every dot is in its own Molstar group because they may have colors and we look that up by group.
let index = 0;
for (let i = 0; i < dotLists.length; i++) {
const dotList = dotLists[i];
const positionArray = dotList.positionArray;
const colorArray = dotList.colorArray;
const labelArray = dotList.labelArray;
const masterArray = dotList.masterArray;
// Check the visibility of all of our masters and skip this dot list if any of them are not visible.
const visible = getVisibility(dotList.group, dotList.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
const numDots = positionArray.length / 3;
for (let j = 0; j < numDots; j++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = dotList.pointmasterArray[j];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
const group = index++;
builderState.add(positionArray[3 * j + 0], positionArray[3 * j + 1], positionArray[3 * j + 2], group);
// colorArray may be undefined; push a default color when not provided
colors.push(colorArray && colorArray.length > j ? colorArray[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(labelArray && labelArray.length > j ? labelArray[j] : '');
}
}
const points = builderState.getPoints();
return { points, colors, labels };
}
async function getLines(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const vectorLists: VectorList[] = kin.vectorLists;
const builderState = LinesBuilder.create();
const widths: number[] = [];
const colors: Color[] = [];
const labels: string[] = [];
// Every line is in its own Molstar group because they may have individual widths and we look
// up the width based on the group is in the size function.
let index = 0;
for (let i = 0; i < vectorLists.length; i++) {
const vectorList = vectorLists[i];
const position1Array = vectorList.position1Array;
const position2Array = vectorList.position2Array;
const widthArray = vectorList.width;
const color1Array = vectorList.color1Array;
const color2Array = vectorList.color2Array;
const label1Array = vectorList.label1Array;
const label2Array = vectorList.label2Array;
const masterArray = vectorList.masterArray;
// Check the visibility of all of our masters and skip this vector list if any of them are not visible.
const visible = getVisibility(vectorList.group, vectorList.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
const numLines = position1Array.length / 3;
for (let j = 0; j < numLines; j++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = vectorList.pointmasterArray[j];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
// Find the midpoint of the line because we're going to actually make
// two half-lines so that labels and selection work better.
const midX = (position1Array[3 * j + 0] + position2Array[3 * j + 0]) / 2;
const midY = (position1Array[3 * j + 1] + position2Array[3 * j + 1]) / 2;
const midZ = (position1Array[3 * j + 2] + position2Array[3 * j + 2]) / 2;
// Make the first half of the line from position1 to the midpoint, labeled and colored based on position1.
let group = index++;
builderState.add(position1Array[3 * j + 0], position1Array[3 * j + 1], position1Array[3 * j + 2],
midX, midY, midZ,
group);
// widthArray may be undefined; push NaN when width not provided
widths.push(widthArray && widthArray.length > j ? widthArray[j] : NaN);
// colorArray may be undefined; push a default color when not provided
colors.push(color1Array && color1Array.length > j ? color1Array[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(label1Array && label1Array.length > j ? label1Array[j] : '');
// Make the second half of the line from the midpoint to position2, labeled and colored based on position2.
group = index++;
builderState.add(midX, midY, midZ,
position2Array[3 * j + 0], position2Array[3 * j + 1], position2Array[3 * j + 2],
group);
// widthArray may be undefined; push NaN when width not provided
widths.push(widthArray && widthArray.length > j ? widthArray[j] : NaN);
// colorArray may be undefined; push a default color when not provided
colors.push(color2Array && color2Array.length > j ? color2Array[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(label2Array && label2Array.length > j ? label2Array[j] : '');
}
}
const lines = builderState.getLines();
return { lines, widths: new Float32Array(widths), colors, labels };
}
function addOffsetTriangle(builderState: MeshBuilder.State, a: Vec3, b: Vec3, c: Vec3, n: Vec3, offset: number) {
const aOffset = Vec3.add(Vec3(), a, Vec3.scale(Vec3(), n, offset));
const bOffset = Vec3.add(Vec3(), b, Vec3.scale(Vec3(), n, offset));
const cOffset = Vec3.add(Vec3(), c, Vec3.scale(Vec3(), n, offset));
MeshBuilder.addTriangleWithNormal(builderState, aOffset, bOffset, cOffset, n);
}
async function getMesh(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const ribbonObjects: RibbonObject[] = kin.ribbonLists;
const builderState = MeshBuilder.createState();
const colors: Color[] = [];
const labels: string[] = [];
// Every triangle is in its own Molstar group because they may have individual colors and we look
// up the color based on the group is in the color function.
let index = 0;
for (let ri = 0; ri < ribbonObjects.length; ri++) {
const ribbonObject = ribbonObjects[ri];
const coords = ribbonObject.positionArray;
const colorArray = ribbonObject.colorArray;
const labelArray = ribbonObject.labelArray;
const masterArray = ribbonObject.masterArray;
const pointMasterArray = ribbonObject.pointmasterArray;
// Check the visibility of all of our masters and skip this ribbon object if any of them are not visible.
const visible = getVisibility(ribbonObject.group, ribbonObject.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
builderState.currentGroup = ri; // TODO: Base this on something in the file instead?
// The positionArray contains 3x as many entries as there are vertices since it's a catenation of x, y, z for each vertex.
// There are three vertices per triangle.
// TODO: Ribbon lighting is to be set up to make each pair of triangles look like a quad with the same normal.
const numTriangles = coords.length / 9;
let prevTriangleNormal: Vec3 | undefined = undefined;
for (let i = 0; i < numTriangles; i++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = pointMasterArray[3 * i];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
const vertexList: Vec3[] = [];
// Get the vertices for the triangle out of the position array and push them onto a list.
for (let j = 0; j < 3; j++) {
const v = Vec3.zero();
v[0] = coords[3 * (3 * i + j) + 0];
v[1] = coords[3 * (3 * i + j) + 1];
v[2] = coords[3 * (3 * i + j) + 2];
vertexList.push(v);
}
// Set the group per triangle so that we can do per-triangle coloring.
const group = index++;
builderState.currentGroup = group;
// colorArray may be undefined; push a default color when not provided.
// There is one color per group, even if we have two triangles in this group.
const color = colorArray && colorArray.length > i * 3 ? colorArray[3 * i] : Color.fromRgb(255, 255, 255);
colors.push(color);
// labelArray may be undefined; push an empty string when not provided
const label = labelArray && labelArray.length > i ? labelArray[i] : '';
labels.push(label);
// Find the vertics and normal for the triangle.
const a: Vec3 = vertexList[0];
const b: Vec3 = vertexList[1];
const c: Vec3 = vertexList[2];
// Put both orientations of the triangle. Add a small amount along the normal to make them
// not be exactly on top of each other so that we only see the front face of each.
let n = Vec3.zero();
Vec3.triangleNormal(n, a, b, c);
if (i % 2 === 1) {
// For ribbons, every other triangle is meant to be paired with the previous one to make a quad with the same normal.
// So use the same normal for every other triangle.
n = prevTriangleNormal || n;
}
prevTriangleNormal = n;
addOffsetTriangle(builderState, a, b, c, n, 0.01);
// Invert the normal for the back face.
Vec3.negate(n, n);
addOffsetTriangle(builderState, a, c, b, n, 0.01);
}
}
const mesh = MeshBuilder.getMesh(builderState);
return { mesh, colors, labels };
}
/**
* Build spheres geometry and collect per-sphere radii from the KIN BallList entries.
* Returns an object with the Spheres geometry and a Float32Array with per-center radii (one entry per center, in the same order they were added).
*/
async function getSpheres(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const balls: BallList[] = kin.ballLists;
const builderState = SpheresBuilder.create();
const radii: number[] = [];
const colors: Color[] = [];
const labels: string[] = [];
// Every ball is in its own Molstar group because they may have individual radii and we look
// up the radius based on the group is in the size function.
let index = 0;
for (let i = 0; i < balls.length; i++) {
const ballList = balls[i];
const positionArray = ballList.positionArray;
const radiusArray = ballList.radiusArray;
const colorArray = ballList.colorArray;
const masterArray = ballList.masterArray;
// Check the visibility of all of our masters and skip this ball list if any of them are not visible.
const visible = getVisibility(ballList.group, ballList.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
const numBalls = positionArray.length / 3;
for (let j = 0; j < numBalls; j++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = ballList.pointmasterArray[j];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
const group = index++;
builderState.add(positionArray[3 * j + 0], positionArray[3 * j + 1], positionArray[3 * j + 2], group);
// radiusArray may be undefined; push NaN when radius not provided
radii.push(radiusArray && radiusArray.length > j ? radiusArray[j] : NaN);
// colorArray may be undefined; push a default color when not provided
colors.push(colorArray && colorArray.length > j ? colorArray[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(ballList.labelArray && ballList.labelArray.length > j ? ballList.labelArray[j] : '');
}
}
const spheres = builderState.getSpheres();
return { spheres, radii: new Float32Array(radii), colors, labels };
}
function makePointsShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapePointsParams>, shape?: Shape<Points>) => {
// Get our points, adding them from all of the entries in the dot lists
const { points: _points, colors, labels } = await getPoints(ctx, kinData.source, visibilityState);
// Color function signature: (groupId: number, instanceId: number) => Color
// For Lines the groupId corresponds to the line index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
// Label function signature: (groupId: number, instanceId: number) => string
// For Lines the groupId corresponds to the line index (order added).
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Points>(
'kin-points',
kinData.source,
_points,
colorFn, // color function reads per-point colors
() => 1, // size function
labelFn // label function reads per-point labels
);
return _shape;
};
return getShape;
}
function makeLineShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeLinesParams>, shape?: Shape<Lines>) => {
// Get our lines, adding them from all of the entries in the vector lists
const { lines: _lines, widths, colors, labels } = await getLines(ctx, kinData.source, visibilityState);
// Size function signature: (groupId: number, instanceId: number) => number
// For Lines the groupId corresponds to the line index (order added).
const sizeFn = (group: number, instance: number) => {
// We're specifying the radius, which is half the width.
let w = widths[group] / 2.0;
if (w < 1.0) { w = 1.0; }
return Number.isFinite(w) ? w : 1.0;
};
// Color function signature: (groupId: number, instanceId: number) => Color
// For Lines the groupId corresponds to the line index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
// Label function signature: (groupId: number, instanceId: number) => string
// For Lines the groupId corresponds to the line index (order added).
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Lines>(
'kin-lines',
kinData.source,
_lines,
colorFn, // color function reads per-line colors
sizeFn, // size function reads per-line widths
labelFn // label function
);
return _shape;
};
return getShape;
}
function makeMeshShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeMeshParams>, shape?: Shape<Mesh>) => {
let { mesh: _mesh, colors, labels } = await getMesh(ctx, kinData.source, visibilityState);
// Ensure that _mesh is not undifined before we pass it to Shape.create. If it is undefined, create an empty mesh instead.
if (!_mesh) {
console.warn('No mesh could be created from the KIN data. Creating an empty mesh instead.');
_mesh = Mesh.createEmpty();
}
// Color function signature: (groupId: number, instanceId: number) => Color
// For Lines the groupId corresponds to the line index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Mesh>(
'kin-mesh',
kinData.source,
_mesh,
colorFn, // color function reads per-triangle colors
() => 1, // size function
labelFn // label function
);
return _shape;
};
return getShape;
}
/**
* Spheres shape getter: uses per-center radii read from the KIN BallList radiusArray when available.
*/
function makeSpheresShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeSpheresParams>, shape?: Shape<Spheres>) => {
// Build spheres geometry and collect per-center radii
const { spheres: _spheres, radii, colors, labels } = await getSpheres(ctx, kinData.source, visibilityState);
// size function signature: (groupId: number, instanceId: number) => number
// For Spheres the groupId corresponds to the center index (order added).
const sizeFn = (group: number, instance: number) => {
const r = radii[group];
return Number.isFinite(r) ? r : 1.0;
};
// Color function signature: (groupId: number, instanceId: number) => Color
// For Spheres the groupId corresponds to the center index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
// Label function signature: (groupId: number, instanceId: number) => string
// For Spheres the groupId corresponds to the center index (order added).
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Spheres>(
'kin-spheres',
kinData.source,
_spheres,
colorFn, // color function reads per-center colors
sizeFn, // size function reads per-center radii
labelFn // label function
);
return _shape;
};
return getShape;
}
export function shapePointsFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Points, KinShapePointsParams>>('Kin Shape Points Provider', async ctx => {
return {
label: label ?? 'Points',
data: { source, transforms: params?.transforms },
params: createKinShapePointsParams(source),
getShape: makePointsShapeGetter(visibilityState),
geometryUtils: Points.Utils
};
});
}
export function shapeLinesFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Lines, KinShapeLinesParams>>('Kin Shape Lines Provider', async ctx => {
return {
label: label ?? 'Lines',
data: { source, transforms: params?.transforms },
params: createKinShapeLinesParams(source),
getShape: makeLineShapeGetter(visibilityState),
geometryUtils: Lines.Utils
};
});
}
export function shapeMeshFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Mesh, KinShapeMeshParams>>('Kin Shape Mesh Provider', async ctx => {
return {
label: label ?? 'Meshes',
data: { source, transforms: params?.transforms },
params: createKinShapeMeshParams(source),
getShape: makeMeshShapeGetter(visibilityState),
geometryUtils: Mesh.Utils
};
});
}
export function shapeSpheresFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Spheres, KinShapeSpheresParams>>('Kin Shape Spheres Provider', async ctx => {
return {
label: label ?? 'Spheres',
data: { source, transforms: params?.transforms },
params: createKinShapeSpheresParams(source),
getShape: makeSpheresShapeGetter(visibilityState),
geometryUtils: Spheres.Utils
};
});
}

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { Structure } from '../../mol-model/structure';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { CustomPropertyDescriptor } from '../../mol-model/custom-property';
import { CustomStructureProperty } from '../../mol-model-props/common/custom-structure-property';
import { CustomProperty } from '../../mol-model-props/common/custom-property';
import { Task } from '../../mol-task';
import { Kinemage } from './reader/schema';
import { parseKin } from './reader/parser';
export const KinemageParams = {
};
export type KinemageParams = typeof KinemageParams
export type KinemageProps = PD.Values<KinemageParams>
export const KinemageDataParams = {
...KinemageParams
};
export type KinemageDataParams = typeof KinemageDataParams
export type KinemageDataProps = PD.Values<KinemageDataParams>
export { KinemageData };
interface KinemageData {
/**
* List of Kinemages read from one or more files.
*/
readonly kinemages: Kinemage[]
}
const FileSourceParams = {
input: PD.File({ accept: '.kin', multiple: false })
};
type FileSourceProps = PD.Values<typeof FileSourceParams>
namespace KinemageData {
export enum Tag {
Representation = 'kinemage-3d'
}
export const symbols = {
};
async function loadKinemageData(data: string): Promise<Kinemage[]> {
const task = parseKin(data);
const result = await task.run();
if (result.isError) {
throw new Error('Failed to parse KIN data');
}
return result.result;
}
export async function open(file: FileSourceProps | File): Promise<KinemageData> {
let fileToRead: File;
if (file instanceof File) {
fileToRead = file;
} else if (file && file.input && file.input.file) {
fileToRead = file.input.file;
} else {
throw new Error('No file given');
}
const task = Task.create('Load KIN file', async ctx => {
const data = await fileToRead.text();
const kinemages = await loadKinemageData(data);
return kinemages;
});
const kinemages = await task.run();
return { kinemages };
}
}
export const KinemageDataProvider: CustomStructureProperty.Provider<KinemageDataParams, KinemageData> = CustomStructureProperty.createProvider({
label: 'Kinemage',
descriptor: CustomPropertyDescriptor({
name: 'Kinemage_loaded_data',
symbols: KinemageData.symbols,
}),
type: 'root',
defaultParams: KinemageDataParams,
getParams: (data: Structure) => KinemageDataParams,
isApplicable,
obtain: async (ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageDataProps>) => {
const p = { ...PD.getDefaultValues(KinemageDataParams), ...props };
try {
return { value: await computeKinemageProps(ctx, data, p) };
} catch (e) {
// the "Residues Embedded in Membrane" symbol may bypass isApplicable() checks
console.warn('Failed to predict membrane orientation. This happens for short peptides and entries without amino acids.');
return { value: undefined };
}
}
});
function isApplicable(structure: Structure) {
return false;
}
async function computeKinemageProps(ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageProps>): Promise<KinemageData> {
// Return an empty KinemageData object since the actual data will be loaded asynchronously via the `open` method.
// This allows the property to be attached to the structure without blocking on file loading.
return {
kinemages: []
};
}

View File

@@ -0,0 +1,939 @@
/**
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Based on earlier kin-parser.ts file from the NGL project (see second author notice below).
* @file Ported NGL-based Kinemage file parser
* @author ReliaSolve <russ@reliasolve.com>
* @private
*/
/**
* file Kin Parser
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Kinemage, RibbonObject } from './schema';
import { Hsv } from '../../../mol-util/color/spaces/hsv';
import { Color } from '../../../mol-util/color';
const ColorDict: { [k: string]: Color } = {
red: Hsv.toColor(Hsv.fromArray([0, 100, 100])),
orange: Hsv.toColor(Hsv.fromArray([20, 100, 100])),
gold: Hsv.toColor(Hsv.fromArray([40, 100, 100])),
yellow: Hsv.toColor(Hsv.fromArray([60, 100, 100])),
lime: Hsv.toColor(Hsv.fromArray([80, 100, 100])),
green: Hsv.toColor(Hsv.fromArray([120, 80, 100])),
sea: Hsv.toColor(Hsv.fromArray([150, 100, 100])),
cyan: Hsv.toColor(Hsv.fromArray([180, 100, 85])),
sky: Hsv.toColor(Hsv.fromArray([210, 75, 95])),
blue: Hsv.toColor(Hsv.fromArray([240, 70, 100])),
purple: Hsv.toColor(Hsv.fromArray([275, 75, 100])),
magenta: Hsv.toColor(Hsv.fromArray([300, 95, 100])),
hotpink: Hsv.toColor(Hsv.fromArray([335, 100, 100])),
pink: Hsv.toColor(Hsv.fromArray([350, 55, 100])),
peach: Hsv.toColor(Hsv.fromArray([25, 75, 100])),
lilac: Hsv.toColor(Hsv.fromArray([275, 55, 100])),
pinktint: Hsv.toColor(Hsv.fromArray([340, 30, 100])),
peachtint: Hsv.toColor(Hsv.fromArray([25, 50, 100])),
yellowtint: Hsv.toColor(Hsv.fromArray([60, 50, 100])),
greentint: Hsv.toColor(Hsv.fromArray([135, 40, 100])),
bluetint: Hsv.toColor(Hsv.fromArray([220, 40, 100])),
lilactint: Hsv.toColor(Hsv.fromArray([275, 35, 100])),
white: Hsv.toColor(Hsv.fromArray([0, 0, 100])),
gray: Hsv.toColor(Hsv.fromArray([0, 0, 50])),
brown: Hsv.toColor(Hsv.fromArray([20, 45, 75])),
deadwhite: Hsv.toColor(Hsv.fromArray([0, 0, 100])),
deadblack: Hsv.toColor(Hsv.fromArray([0, 0, 0])),
invisible: Hsv.toColor(Hsv.fromArray([0, 0, 0]))
};
const reWhitespaceComma = /[\s,]+/;
const reCurlyWhitespace = /[^{}\s]*{[^{}]+}|[^{}\s]+/g;
const reTrimCurly = /^{+|}+$/g;
const reCollapseEqual = /\s*=\s*/g;
function parseListDef(line: string, localColorDict: { [k: string]: Color }) {
let name;
let defaultColor: Color = localColorDict['white']; // Default color is white, but it can be overridden by the list definition
let radius;
let nobutton = false;
const master = [];
let width = 2; // Default width is 2, but it can be overridden by the list definition
line = line.replace(reCollapseEqual, '=');
const lm = line.match(reCurlyWhitespace) as RegExpMatchArray;
for (let j = 1; j < lm.length; ++j) {
const e = lm[j];
if (e[0] === '{') {
name = e.substring(1, e.length - 1);
} else {
const es = e.split('=');
if (es.length === 2) {
if (es[0] === 'color') {
const colorName = parseStr(es[1]);
defaultColor = localColorDict[colorName];
} else if (es[0] === 'width') {
width = parseInt(es[1]);
} else if (es[0] === 'master') {
master.push(es[1].replace(reTrimCurly, ''));
} else if (es[0] === 'radius') {
radius = parseFloat(es[1]);
} else {
console.log('Kinemage: Unknown list definition term found: ' + es[0]);
}
} else if (e === 'nobutton') {
nobutton = true;
} else {
console.log('Kinemage: Unknown list definition term found: ' + e);
}
}
}
return {
listName: name,
listColor: defaultColor,
listMasters: master,
listWidth: width,
listRadius: radius,
nobutton: nobutton
};
}
function parseListElm(line: string, localColorDict: { [k: string]: Color }) {
line = line.trim();
const idx1 = line.indexOf('{');
const idx2 = line.indexOf('}');
const ls = line.substr(idx2 + 1).split(reWhitespaceComma);
const label = line.substr(idx1 + 1, idx2 - 1);
const position = [
parseFloat(ls[ls.length - 3]),
parseFloat(ls[ls.length - 2]),
parseFloat(ls[ls.length - 1])
];
let color, width, radius;
let lineBreak = false;
let triangleBreak = false;
const pointMasters: string[] = [];
for (let lsindex = 4; lsindex <= ls.length; lsindex++) {
const literal = ls[ls.length - lsindex];
if (literal in localColorDict) {
color = localColorDict[ls[ls.length - lsindex]];
}
if (literal.startsWith('width')) {
width = parseInt(literal.substring(5));
}
if (literal.startsWith('r=')) {
radius = parseFloat(literal.split('=')[1]);
}
if (literal.startsWith('P')) {
lineBreak = true;
}
if (literal.startsWith('X')) {
triangleBreak = true;
}
if (literal.startsWith("'") && literal.endsWith("'")) {
// Handle single-character tags by putting each character into a pointMaster tag, e.g. 'ab' would be two tags, 'a' and 'b'
const tagString: string = literal.substring(1, literal.length - 1);
for (let i = 0; i < tagString.length; i++) {
pointMasters.push(tagString[i]);
}
}
}
return {
label: label,
position: position,
color: color,
radius: radius,
width: width,
isLineBreak: lineBreak,
isTriangleBreak: triangleBreak,
pointMasters: pointMasters
};
}
function parseStr(line: string) {
const start = line.indexOf('{');
const end = line.indexOf('}');
return line.substring(
start !== -1 ? start + 1 : 0,
end !== -1 ? end : undefined
).trim();
}
function parseFlag(line: string) {
const end = line.indexOf('}');
return end === -1 ? undefined : line.substr(end + 1).trim();
}
function parseGroup(line: string) {
let name: string = '';
const master: string[] = [];
const flags: { [k: string]: string | boolean } = {};
line = line.replace(reCollapseEqual, '=');
const lm = line.match(reCurlyWhitespace) as RegExpMatchArray;
for (let j = 1; j < lm.length; ++j) {
const e = lm[j];
if (e[0] === '{') {
name = e.substring(1, e.length - 1);
} else {
const es = e.split('=');
if (es.length === 2) {
if (es[0] === 'master') {
master.push(es[1].replace(reTrimCurly, ''));
} else {
flags[es[0]] = es[1].replace(reTrimCurly, '');
}
} else {
flags[es[0]] = true;
}
}
}
return {
groupName: name,
groupFlags: flags,
groupMasters: master,
};
}
function parsePointmaster(line: string) {
let name: string = '';
const tags: string[] = [];
let on: boolean | undefined = undefined;
// Find the string name between curly braces, or print an error if not found
const nameMatch = line.match(/{([^}]+)}/);
if (nameMatch) {
name = nameMatch[1];
// Find all characters between the pair of single quotes, which are the tags, and add them to the tags array
const tagMatch = line.match(/'([^']+)'/);
if (tagMatch) {
const tagString: string = tagMatch[1];
for (let i = 0; i < tagString.length; i++) {
tags.push(tagString[i]);
}
// See if the line contains the word "on" or "off" and set the on variable accordingly
if (line.includes(' on')) {
on = true;
} else if (line.includes(' off')) {
on = false;
}
} else {
console.log('Kinemage: Pointmaster definition missing tags: ' + line);
}
} else {
console.log('Kinemage: Pointmaster definition missing name: ' + line);
}
return {
name: name,
tags: tags,
on: on
};
}
function convertKinTriangleArrays(ribbonObject: RibbonObject) {
// have to convert ribbons/triangle lists from stripdrawmode to normal drawmode
// index [ 0 1 2 3 4 5 6 7 8 91011 ]
// label/color/ptm [ 0 1 2 3 4 5 ] to [ 0 1 2 1 2 3 2 3 4 3 4 5 ]
// convertedindex [ 0 1 2 3 4 5 6 7 8 91011121314151617181920212223242526 ]
// index [ 0 1 2 3 4 5 6 7 8 91011121314 ] [ 0 1 2 3 4 5 6 7 8 3 4 5 6 7 8 91011 6 7 8 91011121314 ]
// position [ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ] to [ 0 0 0 1 1 1 2 2 2 1 1 1 2 2 2 3 3 3 2 2 2 3 3 3 4 4 4 ]
const { labelArray, positionArray, colorArray, breakArray } = ribbonObject;
const convertedLabels = [];
for (let i = 0; i < (labelArray.length - 2) * 3; ++i) {
convertedLabels[i] = labelArray[i - Math.floor(i / 3) * 2];
}
const convertedColors = [];
for (let i = 0; i < (colorArray.length - 2) * 3; ++i) {
convertedColors[i] = colorArray[i - Math.floor(i / 3) * 2];
}
const convertedPMs = [];
for (let i = 0; i < (ribbonObject.pointmasterArray.length - 2) * 3; ++i) {
convertedPMs[i] = ribbonObject.pointmasterArray[i - Math.floor(i / 3) * 2];
}
const convertedBreaks = [];
for (let i = 0; i < (breakArray.length - 2) * 3; ++i) {
convertedBreaks[i] = breakArray[i - Math.floor(i / 3) * 2];
}
const convertedPositions = [];
for (let i = 0; i < (positionArray.length / 3 - 2) * 9; ++i) {
convertedPositions[i] = positionArray[i - Math.floor(i / 9) * 6];
}
const vector3Positions = [];
for (let i = 0; i < (convertedPositions.length) / 3; ++i) {
vector3Positions.push([convertedPositions[i * 3], convertedPositions[i * 3] + 1, convertedPositions[i * 3] + 2]);
}
return {
group: ribbonObject.group,
subgroup: ribbonObject.subgroup,
name: ribbonObject.name,
masterArray: ribbonObject.masterArray,
pointmasterArray: convertedPMs,
nobutton: ribbonObject.nobutton,
labelArray: convertedLabels,
positionArray: convertedPositions,
breakArray: convertedBreaks,
colorArray: convertedColors,
pairTriangleNormals: ribbonObject.pairTriangleNormals
};
}
function removePointBreaksTriangleArrays(convertedRibbonObject: RibbonObject) {
// after converting ribbon/triangle arrys to drawmode, removed point break triangles
// label/color [ 0 1 2 3 4 5 ] to [ 0 1 2 1 2 3 2 3 4 3 4 5 ]
// position [ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ] to [ 0 0 0 1 1 1 2 2 2 1 1 1 2 2 2 3 3 3 2 2 2 3 3 3 4 4 4 ]
const { labelArray, positionArray, colorArray, breakArray } = convertedRibbonObject;
const editedLabels = [];
const editedPositions = [];
const editedColors = [];
const editedPMs = [];
const editedBreaks = [];
for (let i = 0; i < breakArray.length / 3; i++) {
const breakPointer = i * 3;
const positionPointer = i * 9;
if (!breakArray[breakPointer + 1] && !breakArray[breakPointer + 2]) {
editedLabels.push(labelArray[breakPointer]);
editedLabels.push(labelArray[breakPointer + 1]);
editedLabels.push(labelArray[breakPointer + 2]);
editedBreaks.push(breakArray[breakPointer]);
editedBreaks.push(breakArray[breakPointer + 1]);
editedBreaks.push(breakArray[breakPointer + 2]);
editedPositions.push(positionArray[positionPointer]);
editedPositions.push(positionArray[positionPointer + 1]);
editedPositions.push(positionArray[positionPointer + 2]);
editedPositions.push(positionArray[positionPointer + 3]);
editedPositions.push(positionArray[positionPointer + 4]);
editedPositions.push(positionArray[positionPointer + 5]);
editedPositions.push(positionArray[positionPointer + 6]);
editedPositions.push(positionArray[positionPointer + 7]);
editedPositions.push(positionArray[positionPointer + 8]);
editedColors.push(colorArray[breakPointer]);
editedColors.push(colorArray[breakPointer + 1]);
editedColors.push(colorArray[breakPointer + 2]);
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer]);
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer + 1]);
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer + 2]);
}
}
return {
group: convertedRibbonObject.group,
subgroup: convertedRibbonObject.subgroup,
name: convertedRibbonObject.name,
masterArray: convertedRibbonObject.masterArray,
pointmasterArray: editedPMs,
nobutton: convertedRibbonObject.nobutton,
labelArray: editedLabels,
positionArray: editedPositions,
breakArray: editedBreaks,
colorArray: editedColors,
pairTriangleNormals: convertedRibbonObject.pairTriangleNormals
};
}
class KinParser {
// @brief Property that is filled in by the constructor as it parses the file. Read by the caller.
kinemage: Kinemage;
// @brief Constructor for the KinParser class.
// @param data The string data to be parsed, including all lines in the file.
constructor(data: string) {
this._parse(data);
}
private _parse(data: string) {
// http://kinemage.biochem.duke.edu/software/king.php
const kinemage: Kinemage = {
comments: [],
kinemage: undefined,
onewidth: undefined,
viewDict: {},
pdbfile: undefined,
texts: [],
text: '',
captions: [],
caption: '',
groupDict: {},
subgroupDict: {},
masterDict: {},
pointmasterDict: {},
dotLists: [],
vectorLists: [],
ballLists: [],
ribbonLists: [],
groupsAnimate: [],
activeAnimateGroup: -1,
groupsAnimate2: [],
activeAnimateGroup2: -1
};
this.kinemage = kinemage;
// Keep a local copy of the ColorDict that we can update with new colors defined in the file.
const localColorDict: { [k: string]: Color } = Object.assign({}, ColorDict);
let currentGroup: string = '';
let currentGroupMasters: string[];
let currentSubgroup: string = '';
let currentSubgroupMasters: string[];
let isDotList = false;
let prevDotLabel = '';
let dotDefaultColor: Color;
let dotLabel: string[], dotPosition: number[], dotColor: Color[], dotPointMasters: string[][];
let isVectorList = false;
let prevVecLabel = '';
let prevVecPosition: number[] | null = null;
let prevVecColor: Color | null = null;
let vecDefaultColor: Color, vecDefaultWidth: number;
let vecLabel1: string[], vecLabel2: string[], vecPosition1: number[], vecPosition2: number[], vecColor1: Color[], vecColor2: Color[];
let vecWidth: number[], vecPointMasters: string[][];
let isBallList = false;
let prevBallLabel = '';
let ballRadius: number[], ballDefaultColor: Color, ballDefaultRadius: number;
let ballLabel: string[], ballPosition: number[], ballColor: Color[], ballPointMasters: string[][];
let isRibbonList = false;
let ribbonIsTriangles = false;
let prevRibbonPointLabel = '';
let ribbonListDefaultColor: Color = localColorDict['white'];
let ribbonPointLabelArray: string[], ribbonPointPositionArray: number[], ribbonPointBreakArray: boolean[], ribbonPointColorArray: Color[];
let ribbonPointMasters: string[][];
let isText = false;
let isCaption = false;
let foundAnimate = false;
let found2Animate = false;
function _parseChunkOfLines(_i: number, _n: number, lines: string[]) {
for (let i = _i; i < _n; ++i) {
const line = lines[i];
if (line[0] === '@') {
isDotList = false;
isVectorList = false;
isBallList = false;
isRibbonList = false;
isText = false;
isCaption = false;
}
if (!line) {
isDotList = false;
isVectorList = false;
isBallList = false;
isRibbonList = false;
} else if (line.startsWith('@dot') /* dot or dotlist */) {
// @dotlist {x} color=white master={vdw contact} master={dots}
let { listColor, listName, listMasters, nobutton } = parseListDef(line, localColorDict);
isDotList = true;
prevDotLabel = '';
dotLabel = [];
dotPosition = [];
dotColor = [];
dotPointMasters = [];
dotDefaultColor = listColor;
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.dotLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: dotPointMasters,
nobutton: nobutton,
labelArray: dotLabel,
positionArray: dotPosition,
colorArray: dotColor
});
} else if (line.startsWith('@vector') /* vector or vectorlist */) {
// @vectorlist {x} color=white master={small overlap} master={dots}
let { listMasters, listName, listWidth, listColor, nobutton } = parseListDef(line, localColorDict);
if (listMasters) {
listMasters.forEach(function (name: string) {
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
});
}
isVectorList = true;
prevVecLabel = '';
prevVecPosition = null;
prevVecColor = null;
vecLabel1 = [];
vecLabel2 = [];
vecPosition1 = [];
vecPosition2 = [];
vecColor1 = [];
vecColor2 = [];
vecWidth = [];
vecDefaultColor = listColor;
vecPointMasters = [];
vecDefaultWidth = 2;
if (listWidth) {
vecDefaultWidth = listWidth;
}
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.vectorLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: vecPointMasters,
nobutton: nobutton,
label1Array: vecLabel1,
label2Array: vecLabel2,
position1Array: vecPosition1,
position2Array: vecPosition2,
color1Array: vecColor1,
color2Array: vecColor2,
width: vecWidth
});
} else if (line.startsWith('@ball') /* ball or balllist*/ || line.startsWith('@sphere') /* sphere or spherelist */) {
let { listName, listColor, listMasters, listRadius, nobutton } = parseListDef(line, localColorDict);
if (listMasters) {
listMasters.forEach(function (name: string) {
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
});
}
isBallList = true;
prevBallLabel = '';
ballLabel = [];
ballRadius = [];
ballPosition = [];
ballColor = [];
ballPointMasters = [];
ballDefaultColor = listColor;
ballDefaultRadius = listRadius !== undefined ? listRadius : 1;
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.ballLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: ballPointMasters,
nobutton: nobutton,
labelArray: ballLabel,
radiusArray: ballRadius,
positionArray: ballPosition,
colorArray: ballColor
});
} else if (line.startsWith('@ribbon') /* ribbon or ribbonlist */ || line.startsWith('@triangle') /* triangle or trianglelist */) {
let { listMasters, listName, listColor, nobutton } = parseListDef(line, localColorDict);
if (listMasters) {
listMasters.forEach(function (name: string) {
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
});
}
isRibbonList = true;
ribbonIsTriangles = line.startsWith('@triangle'); /* triangle or trianglelist */
prevRibbonPointLabel = '';
ribbonPointLabelArray = [];
ribbonPointPositionArray = [];
ribbonPointBreakArray = [];
ribbonPointColorArray = [];
ribbonListDefaultColor = listColor;
ribbonPointMasters = [];
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.ribbonLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: ribbonPointMasters,
nobutton: nobutton,
labelArray: ribbonPointLabelArray,
positionArray: ribbonPointPositionArray,
breakArray: ribbonPointBreakArray,
colorArray: ribbonPointColorArray,
pairTriangleNormals: !ribbonIsTriangles
});
} else if (line.startsWith('@text')) {
isText = true;
kinemage.texts.push(line.substr(5));
} else if (line.startsWith('@caption')) {
isCaption = true;
kinemage.captions.push(line.substr(8));
} else if (isDotList) {
// { CB THR 1 A}sky 'P' 18.915,14.199,5.024
let { label, color, position, pointMasters } = parseListElm(line, localColorDict);
if (label === '"') {
label = prevDotLabel;
} else {
prevDotLabel = label;
}
if (color === undefined) {
color = dotDefaultColor;
}
dotLabel.push(label);
dotPosition.push(...position);
dotColor.push(color);
dotPointMasters.push(pointMasters);
} else if (isVectorList) {
// { n thr A 1 B13.79 1crnFH} P 17.047, 14.099, 3.625 { n thr A 1 B13.79 1crnFH} L 17.047, 14.099, 3.625
const doubleLine = line.replace(/(?!^){/g, '\n{');
const splitLine = doubleLine.split(/\n/);
for (let i2 = 0; i2 < splitLine.length; i2++) {
const singlePointLine = splitLine[i2];
let { label, color, width, position, isLineBreak, pointMasters } = parseListElm(singlePointLine, localColorDict);
if (label === '"') {
label = prevVecLabel;
}
if (color === undefined) {
color = vecDefaultColor;
}
if (!isLineBreak) {
if (prevVecPosition !== null) {
if (width === undefined) {
width = vecDefaultWidth;
}
vecLabel1.push(prevVecLabel);
vecPosition1.push(...prevVecPosition);
vecColor1.push(prevVecColor ? prevVecColor : vecDefaultColor);
vecLabel2.push(label);
vecPosition2.push(...position);
vecColor2.push(color);
vecWidth.push(width);
vecPointMasters.push(pointMasters);
}
}
prevVecLabel = label;
prevVecPosition = position;
prevVecColor = color;
}
} else if (isBallList) {
// {cb arg A 1 1.431 -106.80} r=1.431 39.085, 8.083, 22.182
let { label, radius, color, position, pointMasters } = parseListElm(line, localColorDict);
if (label === '"') {
label = prevBallLabel;
} else {
prevBallLabel = label;
}
if (radius === undefined) {
radius = ballDefaultRadius;
}
if (color === undefined) {
color = ballDefaultColor;
}
ballLabel.push(label);
ballRadius.push(radius);
ballPosition.push(...position);
ballColor.push(color);
ballPointMasters.push(pointMasters);
} else if (isRibbonList) {
let { label, color, position, isTriangleBreak, pointMasters } = parseListElm(line, localColorDict);
if (label === '"') {
label = prevRibbonPointLabel;
} else {
prevRibbonPointLabel = label;
}
if (color === undefined) {
color = ribbonListDefaultColor;
}
ribbonPointLabelArray.push(label);
ribbonPointPositionArray.push(...position);
ribbonPointBreakArray.push(isTriangleBreak);
ribbonPointColorArray.push(color);
ribbonPointMasters.push(pointMasters);
} else if (isText) {
kinemage.texts.push(line);
} else if (isCaption) {
kinemage.captions.push(line);
} else if (line.startsWith('@kinemage')) {
kinemage.kinemage = parseInt(line.substr(9).trim());
} else if (line.startsWith('@onewidth')) {
kinemage.onewidth = true;
} else if (line.startsWith('@pdbfile')) {
kinemage.pdbfile = parseStr(line);
} else if (line.startsWith('@group')) {
const { groupName, groupFlags, groupMasters } = parseGroup(line);
if (!kinemage.groupDict[groupName as string]) {
kinemage.groupDict[groupName as string] = {
dominant: false,
// If the groupFlags include animate or 2animate, set those to true in the groupDict. Otherwise, set them to false.
animate: groupFlags['animate'] ? true : false,
'2animate': groupFlags['2animate'] ? true : false,
// If the foundAnimate or found2Animate flags are true, set off to true; otherwise set it to the flags value.
off: (foundAnimate || found2Animate) ? true : groupFlags['off'] ? true : false
};
// If the animate or 2animate flags are found in the groupFlags, set foundAnimate
// or found2Animate to true, respectively. Also update the list and index.
if (groupFlags['animate']) {
foundAnimate = true;
kinemage.groupsAnimate.push(groupName as string);
kinemage.activeAnimateGroup = 0;
}
if (groupFlags['2animate']) {
found2Animate = true;
kinemage.groupsAnimate2.push(groupName as string);
kinemage.activeAnimateGroup2 = 0;
}
currentGroupMasters = groupMasters;
}
currentGroup = groupName;
if (currentGroupMasters) {
currentGroupMasters.forEach(function (master) {
if (!kinemage.masterDict[master]) {
kinemage.masterDict[master] = {
indent: false,
visible: true
};
}
});
}
for (const key in groupFlags as { [k: string]: boolean }) {
kinemage.groupDict[groupName as string][key] = (groupFlags as { [k: string]: boolean })[key];
}
} else if (line.startsWith('@subgroup')) {
const { groupName, groupFlags, groupMasters } = parseGroup(line);
const combinedName = currentGroup + ':' + groupName as string;
if (!kinemage.subgroupDict[combinedName]) {
kinemage.subgroupDict[combinedName] = {
dominant: false,
// If the groupFlag includes "off", set off to true; otherwise, set it to false.
off: groupFlags['off'] ? true : false,
group: currentGroup
};
currentSubgroupMasters = groupMasters;
}
currentSubgroup = combinedName;
if (currentSubgroupMasters) {
currentSubgroupMasters.forEach(function (master) {
if (!kinemage.masterDict[master]) {
kinemage.masterDict[master] = {
indent: false,
visible: true
};
}
});
}
for (const key in groupFlags as { [k: string]: boolean }) {
kinemage.subgroupDict[combinedName as string][key] = (groupFlags as { [k: string]: boolean })[key];
}
} else if (line.startsWith('@master')) {
const name = parseStr(line);
const flag = parseFlag(line);
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
// TODO: There can be more than one flag on a @master line: indent, off, nobutton
if (flag === 'on') {
kinemage.masterDict[name].visible = true;
} else if (flag === 'off') {
kinemage.masterDict[name].visible = false;
} else if (flag === 'indent') {
kinemage.masterDict[name].indent = true;
} else if (!flag) {
// nothing to do
}
} else if (line.startsWith('@pointmaster')) {
const { name, tags, on } = parsePointmaster(line);
if (name.length > 0 && tags.length > 0) {
// Ensure that we have a masterDict entry for this pointmaster name, even though it doesn't have any flags of its own.
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: on !== false // If the on variable is explicitly false, set visible to false. Otherwise, set it to true.
};
}
// Add the mapping to point each single-character tag to the pointmaster name in the pointmasterDict.
for (let i = 0; i < tags.length; i++) {
kinemage.pointmasterDict[tags[i]] = name;
}
}
} else if (line.startsWith('@colorset')) {
// We have a string inside curly brackets {} followed by the name of an existing dictionary color.
const colorName = parseStr(line);
const colorReference = parseFlag(line);
if (colorReference && colorReference in localColorDict) {
localColorDict[colorName] = localColorDict[colorReference];
}
} else if (/^@(\d*)viewid\b/.test(line)) {
const m = line.match(/^@(\d*)viewid\b/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].name = parseStr(line);
} else if (/^@(\d*)center\b/.test(line)) {
// Match all of the line after center as another string.
const m = line.match(/^@(\d*)center\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the three whitespace-separated numbers after the keyword. Parse each as a float and
// add them to a length-3 list of numbers.
const rest = (m && m[2]) ? m[2].trim() : '';
// Split on whitespace and take the first three tokens, parsed as floating-point numbers, as the center coordinates.
const parts = rest.length > 0 ? rest.split(/\s+/).filter(Boolean) : [];
const centerTokens = parts.slice(0, 3).map(parseFloat);
// If the length is 3 and all are valid numbers, add the list of three numbers to the view dictionary.
if (centerTokens.length === 3 && centerTokens.every(num => !isNaN(num))) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].center = centerTokens;
}
} else if (/^@(\d*)matrix\b/.test(line)) {
// Match all of the line after matrix as another string.
const m = line.match(/^@(\d*)matrix\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the nine whitespace-separated numbers after the keyword. Parse each as a float and
// add them to a length-9 list of numbers.
const rest = (m && m[2]) ? m[2].trim() : '';
// Split on whitespace and take the first nine tokens, parsed as floating-point numbers, as the matrix values.
const parts = rest.length > 0 ? rest.split(/\s+/).filter(Boolean) : [];
const matrixTokens = parts.slice(0, 9).map(parseFloat);
// If the length is 9 and all are valid numbers, add the list of nine numbers to the view dictionary.
if (matrixTokens.length === 9 && matrixTokens.every(num => !isNaN(num))) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].matrix = matrixTokens;
}
} else if (/^@(\d*)span\b/.test(line)) {
// Match all of the line after span as another string.
const m = line.match(/^@(\d*)span\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the remainder of the line and parse it as a float.
const rest = (m && m[2]) ? m[2].trim() : '';
const spanValue = parseFloat(rest);
// If it is a valid number, add it to the view dictionary.
if (!isNaN(spanValue)) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].span = spanValue;
}
} else if (/^@(\d*)zoom\b/.test(line)) {
// Match all of the line after zoom as another string.
const m = line.match(/^@(\d*)zoom\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the remainder of the line and parse it as a float.
const rest = (m && m[2]) ? m[2].trim() : '';
const zoomValue = parseFloat(rest);
// If it is a valid number, add it to the view dictionary.
if (!isNaN(zoomValue)) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].zoom = zoomValue;
}
} else if (/^@(\d*)zslab\b/.test(line)) {
// Match all of the line after zslab as another string.
const m = line.match(/^@(\d*)zslab\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the remainder of the line and parse it as a float.
const rest = (m && m[2]) ? m[2].trim() : '';
const zslabValue = parseFloat(rest);
// If it is a valid number, add it to the view dictionary.
if (!isNaN(zslabValue)) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].zslab = zslabValue;
}
} else {
console.log('Kinemage: Unrecognized line: ' + line);
}
}
}
// Break the file into a list of lines and then parse them all.
const lines = data.split(/\r?\n/);
_parseChunkOfLines(0, lines.length, lines);
kinemage.text = kinemage.texts.join('\n').trim();
kinemage.caption = kinemage.captions.join('\n').trim();
if (kinemage.ribbonLists) {
const convertedLists: RibbonObject[] = [];
kinemage.ribbonLists.forEach(function (listObject) {
convertedLists.push(removePointBreaksTriangleArrays(convertKinTriangleArrays(listObject)));
});
kinemage.ribbonLists = convertedLists;
}
}
}
export { KinParser };

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { ReaderResult as Result } from '../../../mol-io/reader/result';
import { Task, RuntimeContext } from '../../../mol-task';
import { Kinemage } from './schema';
import { KinParser } from './kinparser';
async function parseInternal(data: string, ctx: RuntimeContext): Promise<Result<Kinemage[]>> {
const kinemages: Kinemage[] = [];
// Split the data into sections based on the '@kinemage' keyword, which indicates one or more kinemages in the file.
// Handle the case where there is no '@kinemage' keyword by parsing the entire file.
const kinemageSections = data.split(/@kinemage\s+\d+/); // Split based on '@kinemage' keyword followed by a number
// If there are one or more @kinemage sections, ignore the portion before the first one.
// This will either be an empty string (if the first section starts at the beginning of the file)
// or header data that is not part of a particular kinemage. This has the effect of removing
// the header data even in the case where there is a single @kinemage keyword.
if (kinemageSections.length > 1) {
kinemageSections.shift();
}
for (const section of kinemageSections) {
if (section.trim()) { // Ignore empty sections
const NGLParser = new KinParser(section.trim());
const kinData = NGLParser.kinemage;
kinemages.push(kinData);
}
}
return Result.success(kinemages);
}
export function parseKin(data: string) {
return Task.create<Result<Kinemage[]>>('Parse KIN', async ctx => {
return await parseInternal(data, ctx);
});
}

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { Color } from '../../../mol-util/color';
export interface Kinemage {
readonly comments: ReadonlyArray<string>
kinemage?: number,
onewidth?: any,
viewDict: { [id: number]: View },
pdbfile?: string,
text: string,
texts: string[],
captions: string[],
caption: string,
groupDict: { [k: string]: { [k: string]: boolean } },
subgroupDict: { [k: string]: any }, // /< Subgroup key is "GroupName:SubgroupName" to preserve tree structure
masterDict: { [k: string]: { indent: boolean, visible: boolean } },
pointmasterDict: { [k: string]: string }, // /< Maps from single-character name to master name for points, e.g. 'a' -> 'alta'
dotLists: DotList[],
vectorLists: VectorList[],
ballLists: BallList[],
ribbonLists: RibbonObject[],
groupsAnimate: string[],
activeAnimateGroup: number,
groupsAnimate2: string[],
activeAnimateGroup2: number,
viewSnapshots?: {} // /< Used to store view snapshots in behavior.ts to use in ui.tsx
}
/** Common base for all list-like objects in a kinemage */
export interface KinListBase {
name?: string, // /< Optional name of the whole List
group: string, // /< Name of the group this List belongs to (may be '' if no group)
subgroup: string, // /< Name of the subgroup this List belongs to (may be '' if no subgroup)
nobutton: boolean, // /< Whether the list is a nobutton list (true if 'nobutton' keyword found)
masterArray: any[], // /< Array of master names per List, not per element
pointmasterArray: string[][] // /< Array of point master names per element
}
export interface DotList extends KinListBase {
labelArray: string[], // /< Array of labels per element
positionArray: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
colorArray: Color[] // /< Color for each element, as many as elements
}
export interface BallList extends KinListBase {
labelArray: string[], // /< Array of labels per element
positionArray: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
colorArray: Color[], // /< Color for each element, as many as elements
radiusArray: number[] // /< A single radius per element
}
export interface RibbonObject extends KinListBase {
labelArray: string[], // /< Array of labels per element
positionArray: number[], // /< Catenation of x, y, z for each element, 9x as many as triangles (3 vertices per triangle)
colorArray: Color[], // /< Color for each element, as many as elements
breakArray: boolean[], // /< A single boolean per element indicating if there is a break there
pairTriangleNormals: boolean // /< Whether to pair every other triangle normal for lighting (true for ribbons, false for triangles)
}
export interface VectorList extends KinListBase {
label1Array: string[], // /< Array of labels for the first half of each element
label2Array: string[], // /< Array of labels for the second half of each element
position1Array: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
position2Array: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
color1Array: Color[], // /< Color for first half of each element, as many as elements
color2Array: Color[], // /< Color for second half of each element, as many as elements
width: number[] // /< A single width per element
}
export interface View {
name?: string, // /< Optional name of the View
center?: number[], // /< X, Y, Z of the center of the view; the model rotates around this point
matrix?: number[], // /< Specifies and orthonormal rotation matrix defining view orientation
span?: number, // /< Specifies the (smaller of) width or height of the view in world coordinates at the center
zoom?: number, // /< Alternate zoom specification, indicates how much of the model is visible, 1=all, 2=half
zslab?: number // /< Distance from the center to the near and far clipping planes, 200 means same as span (half is percent of half span)
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Representation, RepresentationContext, RepresentationParamsGetter } from '../../mol-repr/representation';
import { Structure } from '../../mol-model/structure';
import { StructureRepresentation, StructureRepresentationStateBuilder } from '../../mol-repr/structure/representation';
import { ThemeRegistryContext } from '../../mol-theme/theme';
// TODO: Convert this approach to a more usual one that creates visuals during parse and shows them
// during visuals.
const KinemageDataVisuals = {
};
export const KinemageDataParams = {
visuals: PD.MultiSelect([], PD.objectToOptions(KinemageDataVisuals)),
};
export type KinemageDataParams = typeof KinemageDataParams
export type KinemageDataProps = PD.Values<KinemageDataParams>
export function getKinemageDataParams(ctx: ThemeRegistryContext, structure: Structure) {
return PD.clone(KinemageDataParams);
}
export type KinemageDataRepresentation = StructureRepresentation<KinemageDataParams>
export function KinemageDataRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, KinemageDataParams>): KinemageDataRepresentation {
return Representation.createMulti('Membrane Orientation', ctx, getParams, StructureRepresentationStateBuilder, KinemageDataVisuals as unknown as Representation.Def<Structure, KinemageDataParams>);
}

View File

@@ -0,0 +1,429 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/**
* Kinemage right-panel controls (right-panel only).
*
* Shows kinemage views, animate buttons, and group/subgroup/master toggles in the right inspector.
* Controls update visibility controller parameters which trigger rebuilds via the state tree.
*/
import * as React from 'react';
import { CollapsableState, CollapsableControls } from '../../mol-plugin-ui/base';
import { Camera } from '../../mol-canvas3d/camera';
import { applyViewSnapshot } from './behavior';
import { Kinemage } from './reader/schema';
import { StateTransforms } from '../../mol-plugin-state/transforms';
import { KinemageShapePointsProvider, KinemageShapeLinesProvider, KinemageShapeMeshProvider, KinemageShapeSpheresProvider } from './behavior';
interface KinemageControlState extends CollapsableState {
isBusy: boolean
}
function nameFromString(s: string | undefined) {
// If this is undefined, return undefined.
if (!s) return undefined;
// Return up to the first 30 characters of the string.
return s.length > 30 ? s.substring(0, 30) + '...' : s;
}
export class KinemageControls extends CollapsableControls<{}, KinemageControlState> {
protected defaultState(): KinemageControlState {
return {
header: 'Kinemage',
isCollapsed: false,
isBusy: false,
// default hidden until a kinemage is present
isHidden: true,
brand: { accent: 'cyan', svg: undefined as any }
};
}
componentDidMount() {
// Listen for shape/state changes: when state tree cells are created or removed the visuals changed.
this.subscribe(this.plugin.state.data.events.cell.created, (e: any) => this.onCellCreated(e));
this.subscribe(this.plugin.state.data.events.cell.removed, () => this.onCellRemoved());
// also track cell state updates that may change labels / visibility
this.subscribe(this.plugin.state.data.events.cell.stateUpdated, () => this.forceUpdate());
// ensure initial visibility reflects current state
this.updateVisibility();
}
private onCellCreated(e: any) {
this.updateVisibility();
}
private onCellRemoved() {
this.updateVisibility();
}
private updateVisibility() {
const kinemages = this.getKinemageList();
this.setState({ isHidden: kinemages.length === 0 });
}
private getKinemageList(): Array<{ kinData: Kinemage, ref: string, visControllerRef: string }> {
const result: Array<{ kinData: Kinemage, ref: string, visControllerRef: string }> = [];
try {
const cells = (this.plugin.state.data as any).cells as Map<string, any>;
for (const [ref, entry] of cells) {
const obj = (entry as any).obj;
// Look for Format.Json nodes that contain kinData and visibilityState (visibility controller)
if (obj && obj.data && (obj.data as any).kinData && (obj.data as any).visibilityState) {
result.push({
kinData: (obj.data as any).kinData,
ref,
visControllerRef: ref
});
}
}
} catch (e) {
console.warn('Failed to enumerate kinemage nodes', e);
}
return result;
}
private getAllDescendants(nodeRef: string): string[] {
const result: string[] = [];
const tree = this.plugin.state.data.tree;
const queue = [nodeRef];
while (queue.length > 0) {
const current = queue.shift()!;
const children = tree.children.get(current);
if (children) {
for (const childRef of children.values()) {
result.push(childRef);
queue.push(childRef);
}
}
}
return result;
}
private async applyView(kinData: Kinemage, viewKey: string) {
const snap = (kinData as any).viewSnapshots?.[viewKey];
if (snap) {
await applyViewSnapshot(this.plugin, snap as Partial<Camera.Snapshot>);
}
}
private async rebuildShapes(visControllerRef: string, kinData: Kinemage) {
const update = this.plugin.state.data.build();
// Delete all descendants (shape providers and representations)
const descendants = this.getAllDescendants(visControllerRef);
for (const nodeRef of descendants) {
update.delete(nodeRef);
}
await update.commit();
// Recreate shapes
const rebuildUpdate = this.plugin.state.data.build();
// Generate all shape types that have data, each as child of the visibility controller
if (kinData.dotLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.vectorLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.ribbonLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true });
}
if (kinData.ballLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
await rebuildUpdate.commit();
}
private async toggleVisibility(visControllerRef: string, kinData: Kinemage, target: { type: 'group' | 'subgroup' | 'master', key: string }) {
try {
const cell = this.plugin.state.data.cells.get(visControllerRef);
if (!cell || !cell.transform || !cell.transform.params) return;
const currentParams = cell.transform.params;
const newGroupVisibility = { ...currentParams.groupVisibility };
const newSubgroupVisibility = { ...currentParams.subgroupVisibility };
const newMasterVisibility = { ...currentParams.masterVisibility };
if (target.type === 'group') {
newGroupVisibility[target.key] = !newGroupVisibility[target.key];
} else if (target.type === 'subgroup') {
newSubgroupVisibility[target.key] = !newSubgroupVisibility[target.key];
} else {
newMasterVisibility[target.key] = !newMasterVisibility[target.key];
}
const update = this.plugin.state.data.build();
// Update the visibility controller
update.to(visControllerRef).update({
groupVisibility: newGroupVisibility,
subgroupVisibility: newSubgroupVisibility,
masterVisibility: newMasterVisibility
});
await update.commit();
// Rebuild all shapes to reflect new visibility
await this.rebuildShapes(visControllerRef, kinData);
} catch (e) {
console.error('Failed to toggle kinemage visibility', e);
}
}
private async triggerAnimateForKin(visControllerRef: string, kinData: Kinemage, mode: 'animate' | '2animate') {
try {
const cell = this.plugin.state.data.cells.get(visControllerRef);
if (!cell || !cell.transform || !cell.transform.params) return;
const currentParams = cell.transform.params;
const animateGroups = mode === 'animate' ? kinData.groupsAnimate : kinData.groupsAnimate2;
const currentActive = mode === 'animate' ? currentParams.activeAnimateGroup : currentParams.activeAnimateGroup2;
const nextActive = (currentActive + 1) % Math.max(1, animateGroups.length);
// IMPORTANT: Read the CURRENT visibility state from the controller node's data (not params)
// to preserve any changes made through UI interactions
const controllerCell = this.plugin.state.data.cells.get(visControllerRef);
const currentVisibilityState = controllerCell?.obj?.data ? (controllerCell.obj.data as any).visibilityState : null;
// Start with current actual visibility state
const newGroupVisibility = currentVisibilityState
? Object.fromEntries(currentVisibilityState.groupVisibility)
: { ...currentParams.groupVisibility };
// Only update the animate groups - leave everything else as-is
for (let i = 0; i < animateGroups.length; i++) {
newGroupVisibility[animateGroups[i]] = (i === nextActive);
}
const update = this.plugin.state.data.build();
// Update the visibility controller with current visibility PLUS animate changes
const updateParams: any = {
groupVisibility: newGroupVisibility,
};
if (mode === 'animate') {
updateParams.activeAnimateGroup = nextActive;
} else {
updateParams.activeAnimateGroup2 = nextActive;
}
// Also preserve other visibility states
if (currentVisibilityState) {
updateParams.subgroupVisibility = Object.fromEntries(currentVisibilityState.subgroupVisibility);
updateParams.masterVisibility = Object.fromEntries(currentVisibilityState.masterVisibility);
} else {
updateParams.subgroupVisibility = currentParams.subgroupVisibility;
updateParams.masterVisibility = currentParams.masterVisibility;
}
update.to(visControllerRef).update(updateParams);
await update.commit();
// Rebuild all shapes to reflect new visibility
await this.rebuildShapes(visControllerRef, kinData);
} catch (e) {
console.error('Failed to trigger animate', e);
}
}
private isVisible(visControllerRef: string, target: { type: 'group' | 'subgroup' | 'master', key: string }): boolean {
try {
const cell = this.plugin.state.data.cells.get(visControllerRef);
if (!cell || !cell.transform || !cell.transform.params) return true;
const params = cell.transform.params;
if (target.type === 'group') {
return params.groupVisibility[target.key] !== false;
} else if (target.type === 'subgroup') {
return params.subgroupVisibility[target.key] !== false;
} else {
return params.masterVisibility[target.key] !== false;
}
} catch (e) {
return true;
}
}
renderControls() {
const kins = this.getKinemageList();
if (kins.length === 0) return <div style={{ padding: '6px' }}>No Kinemage data</div>;
const blocks: React.ReactNode[] = [];
for (const { kinData, visControllerRef } of kins) {
const title = kinData.pdbfile || nameFromString(kinData.caption) || 'Kinemage';
const kinBlock: React.ReactNode[] = [];
// Title
kinBlock.push(
<div key={'title-' + title} style={{ padding: '6px', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
{title}
</div>
);
// views
const viewEntries = Object.entries(kinData.viewDict || {});
if (viewEntries.length > 0) {
for (const [viewKey, viewObj] of viewEntries) {
const label = `View ${viewObj.name || `View ${viewKey}`}`;
kinBlock.push(
<div key={'view-' + title + '-' + viewKey} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.applyView(kinData, viewKey)}
title={`Apply view: ${label}`}
>
{label}
</button>
</div>
);
}
}
// animate
if (kinData.groupsAnimate && kinData.groupsAnimate.length > 0) {
kinBlock.push(
<div key={'anim-' + title} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.triggerAnimateForKin(visControllerRef, kinData, 'animate')}
title='Cycle through animation frames'
>
Animate
</button>
</div>
);
}
if (kinData.groupsAnimate2 && kinData.groupsAnimate2.length > 0) {
kinBlock.push(
<div key={'anim2-' + title} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.triggerAnimateForKin(visControllerRef, kinData, '2animate')}
title='Cycle through second animation frames'
>
Animate2
</button>
</div>
);
}
// groups
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict || {})) {
if (!(groupInfo as any).nobutton) {
const visible = this.isVisible(visControllerRef, { type: 'group', key: groupKey });
// If this group is in animate or animate2, then add '*' before its groupKey name to indicate that it's an animation group
const isAnimate = (kinData.groupsAnimate?.includes(groupKey) ?? false) || (kinData.groupsAnimate2?.includes(groupKey) ?? false);
const label = isAnimate ? `* ${groupKey}` : groupKey;
kinBlock.push(
<div key={'group-' + title + '-' + groupKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'group', key: groupKey })}
style={{ marginRight: '6px' }}
/>
<span title={label}>{label}</span>
</label>
</div>
);
}
// If this group is not dominant, find any subgroups of this group and show them here (indented) unless they have nobutton set
if (!(groupInfo as any).dominant) {
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
if (subgroupKey.startsWith(groupKey + ':')) {
if ((subgroupInfo as any).nobutton) continue;
const visible = this.isVisible(visControllerRef, { type: 'subgroup', key: subgroupKey });
const subgroupLabel = subgroupKey.split(':')[1];
kinBlock.push(
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px', paddingLeft: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'subgroup', key: subgroupKey })}
style={{ marginRight: '6px' }}
/>
<span title={subgroupLabel}>{subgroupLabel}</span>
</label>
</div>
);
}
}
}
}
// subgroups that don't belong to a group (standalone)
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
// if parent group present, those groups' subgroups are already shown when iterating groups
if (subgroupKey.indexOf(':') !== -1) {
// subgroups with parent group; skip here (shown under parent group)
continue;
}
if ((subgroupInfo as any).nobutton) continue;
const visible = this.isVisible(visControllerRef, { type: 'subgroup', key: subgroupKey });
kinBlock.push(
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'subgroup', key: subgroupKey })}
style={{ marginRight: '6px' }}
/>
<span title={subgroupKey}>{subgroupKey}</span>
</label>
</div>
);
}
// masters
for (const [masterKey] of Object.entries(kinData.masterDict || {})) {
const visible = this.isVisible(visControllerRef, { type: 'master', key: masterKey });
kinBlock.push(
<div key={'master-' + title + '-' + masterKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'master', key: masterKey })}
style={{ marginRight: '6px' }}
/>
<span title={masterKey}>{masterKey}</span>
</label>
</div>
);
}
blocks.push(<div key={'kin-block-' + title} className='msp-control-group-wrapper'>{kinBlock}</div>);
}
return <>{blocks}</>;
}
}

View File

@@ -7,7 +7,7 @@
import { Viewport, cameraProject, cameraUnproject } from './camera/util';
import { CameraTransitionManager } from './camera/transition';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { Scene } from '../mol-gl/scene';
import { assertUnreachable } from '../mol-util/type-helpers';
import { Ray3D } from '../mol-math/geometry/primitives/ray3d';
@@ -15,6 +15,7 @@ import { Mat4 } from '../mol-math/linear-algebra/3d/mat4';
import { Vec4 } from '../mol-math/linear-algebra/3d/vec4';
import { Vec3 } from '../mol-math/linear-algebra/3d/vec3';
import { EPSILON } from '../mol-math/linear-algebra/3d/common';
import { Euler } from '../mol-math/linear-algebra/3d/euler';
export type { ICamera };
@@ -42,6 +43,12 @@ interface ICamera {
}
const tmpClip = Vec4();
const tmpForward = Vec3();
const tmpRight = Vec3();
const tmpUp = Vec3();
const tmpBack = Vec3();
const tmpDelta = Vec3();
const tmpRotMat = Mat4.identity();
export class Camera implements ICamera {
readonly view: Mat4 = Mat4.identity();
@@ -70,6 +77,8 @@ export class Camera implements ICamera {
readonly transition: CameraTransitionManager = new CameraTransitionManager(this);
readonly stateChanged = new BehaviorSubject<Partial<Camera.Snapshot>>(this.state);
/** Fires whenever update() produces a changed view/projection (covers all mutations, including direct ones from controls). */
readonly changed = new Subject<void>();
get position() { return this.state.position; }
set position(v: Vec3) { Vec3.copy(this.state.position, v); }
@@ -123,6 +132,7 @@ export class Camera implements ICamera {
Mat4.copy(this.prevView, this.view);
Mat4.copy(this.prevProjection, this.projection);
this.changed.next();
}
return changed;
@@ -237,6 +247,57 @@ export class Camera implements ICamera {
return out;
}
/** How much the camera is rotated around its target. Uses 'ZYX' order. */
getRotation(out: Euler) {
const { position, target, up } = this.state;
Vec3.normalize(tmpForward, Vec3.sub(tmpForward, target, position));
Vec3.normalize(tmpRight, Vec3.cross(tmpRight, tmpForward, up));
Vec3.cross(tmpUp, tmpRight, tmpForward);
Mat4.setIdentity(tmpRotMat);
tmpRotMat[0] = tmpRight[0]; tmpRotMat[1] = tmpRight[1]; tmpRotMat[2] = tmpRight[2];
tmpRotMat[4] = tmpUp[0]; tmpRotMat[5] = tmpUp[1]; tmpRotMat[6] = tmpUp[2];
tmpRotMat[8] = -tmpForward[0]; tmpRotMat[9] = -tmpForward[1]; tmpRotMat[10] = -tmpForward[2];
return Euler.fromMat4(out, tmpRotMat, 'ZYX');
}
/** Set the camera rotation around its target. Expects 'ZYX' order. */
setRotation(rotation: Euler, durationMs?: number) {
const snapshot = this.state as Camera.Snapshot;
const distance = Vec3.distance(snapshot.position, snapshot.target);
Mat4.fromEuler(tmpRotMat, rotation, 'ZYX');
// back = R * (0,0,1) → column 2 of R
Vec3.set(tmpBack, tmpRotMat[8], tmpRotMat[9], tmpRotMat[10]);
// up = R * (0,1,0) → column 1 of R
Vec3.set(tmpUp, tmpRotMat[4], tmpRotMat[5], tmpRotMat[6]);
const state = Camera.copySnapshot(Camera.createDefaultSnapshot(), snapshot);
Vec3.scaleAndAdd(state.position, snapshot.target, tmpBack, distance);
Vec3.copy(state.up, tmpUp);
this.setState(state, durationMs);
}
/** Translation of the camera target relative to world origin (0, 0, 0) */
getTranslation(out: Vec3) {
return Vec3.copy(out, this.state.target);
}
/** Set the camera target to the given translation, moving position by the same delta so orientation/distance are preserved */
setTranslation(translation: Vec3, durationMs?: number) {
const snapshot = this.state as Camera.Snapshot;
Vec3.sub(tmpDelta, translation, snapshot.target);
const state = Camera.copySnapshot(Camera.createDefaultSnapshot(), snapshot);
Vec3.add(state.position, snapshot.position, tmpDelta);
Vec3.copy(state.target, translation);
this.setState(state, durationMs);
}
constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128)) {
this.viewport = viewport;
Camera.copySnapshot(this.state, state);

View File

@@ -98,7 +98,6 @@ export const Canvas3DParams = {
transparentBackground: PD.Boolean(false),
checkeredTransparentBackground: PD.Boolean(false),
dpoitIterations: PD.Numeric(2, { min: 1, max: 10, step: 1 }),
enableAnimation: PD.Boolean(true, { description: 'Enable GPU time-based animations (wiggle/tumble).' }),
pickPadding: PD.Numeric(3, { min: 0, max: 10, step: 1 }, { description: 'Extra pixels to around target to check in case target is empty.' }),
userInteractionReleaseMs: PD.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'The time before the user is not considered interacting anymore.' }),
@@ -480,7 +479,6 @@ namespace Canvas3D {
const hiZ = new HiZPass(webgl, passes.draw, canvas, p.hiZ);
const renderer = Renderer.create(webgl, p.renderer);
renderer.setProps({ enableAnimation: p.enableAnimation });
renderer.setOcclusionTest(hiZ.isOccluded);
const shaderManager = new ShaderManager(webgl, scene);
@@ -677,7 +675,7 @@ namespace Canvas3D {
const xrChanged = xrManager.update(xrFrame);
if (!xrChanged && xrFrame) return false;
const activeAnimation = p.enableAnimation && scene.hasAnimation;
const activeAnimation = renderer.props.enableAnimation && scene.hasAnimation;
const shouldRender = force || cameraChanged || resized || forceNextRender || xrChanged || activeAnimation;
forceNextRender = false;
@@ -1071,7 +1069,6 @@ namespace Canvas3D {
transparentBackground: p.transparentBackground,
checkeredTransparentBackground: p.checkeredTransparentBackground,
dpoitIterations: p.dpoitIterations,
enableAnimation: p.enableAnimation,
pickPadding: p.pickPadding,
userInteractionReleaseMs: p.userInteractionReleaseMs,
viewport: p.viewport,
@@ -1317,10 +1314,6 @@ namespace Canvas3D {
if (props.transparentBackground !== undefined) p.transparentBackground = props.transparentBackground;
if (props.checkeredTransparentBackground !== undefined) p.checkeredTransparentBackground = props.checkeredTransparentBackground;
if (props.dpoitIterations !== undefined) p.dpoitIterations = props.dpoitIterations;
if (props.enableAnimation !== undefined) {
p.enableAnimation = props.enableAnimation;
renderer.setProps({ enableAnimation: p.enableAnimation });
}
if (props.pickPadding !== undefined) {
p.pickPadding = props.pickPadding;
pickHelper.setPickPadding(p.pickPadding);

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 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>
@@ -484,27 +484,45 @@ export class SsaoPass {
if (isTimingMode) this.webgl.timer.markEnd('SSAO.downsample');
}
if (isTimingMode) this.webgl.timer.mark('SSAO.half');
if (multiScale) {
// half-resolution viewport (matches dimensions of depthHalfTarget*)
const hsx = Math.floor(sx * 0.5);
const hsy = Math.floor(sy * 0.5);
const hsw = Math.ceil(sw * 0.5);
const hsh = Math.ceil(sh * 0.5);
state.viewport(hsx, hsy, hsw, hsh);
state.scissor(hsx, hsy, hsw, hsh);
if (isTimingMode) this.webgl.timer.mark('SSAO.half');
this.depthHalfTargetOpaque.bind();
this.depthHalfRenderableOpaque.render();
}
if (multiScale && includeTransparent) {
this.depthHalfTargetTransparent.bind();
this.depthHalfRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.half');
if (includeTransparent) {
this.depthHalfTargetTransparent.bind();
this.depthHalfRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.half');
if (isTimingMode) this.webgl.timer.mark('SSAO.quarter');
if (multiScale) {
// quarter-resolution viewport (matches dimensions of depthQuarterTarget*)
const qsx = Math.floor(sx * 0.25);
const qsy = Math.floor(sy * 0.25);
const qsw = Math.ceil(sw * 0.25);
const qsh = Math.ceil(sh * 0.25);
state.viewport(qsx, qsy, qsw, qsh);
state.scissor(qsx, qsy, qsw, qsh);
if (isTimingMode) this.webgl.timer.mark('SSAO.quarter');
this.depthQuarterTargetOpaque.bind();
this.depthQuarterRenderableOpaque.render();
if (includeTransparent) {
this.depthQuarterTargetTransparent.bind();
this.depthQuarterRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.quarter');
// restore full-scale viewport for SSAO + blur passes
state.viewport(sx, sy, sw, sh);
state.scissor(sx, sy, sw, sh);
}
if (multiScale && includeTransparent) {
this.depthQuarterTargetTransparent.bind();
this.depthQuarterRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.quarter');
if (isTimingMode) this.webgl.timer.mark('SSAO.opaque');
this.ssaoDepthTexture.attachFramebuffer(this.framebuffer, 'color0');

View File

@@ -72,6 +72,25 @@ export function getColorSmoothingProps(smoothColors: PD.Values<ColorSmoothingPar
//
export type InstanceGranularityValue = true | false | 'auto'
export const InstanceGranularityOptions: [InstanceGranularityValue, string][] = [[true, 'On'], [false, 'Off'], ['auto', 'Auto']];
/**
* Threshold (in `groupCount * instanceCount`, e.g. number of marker-texture
* slots) above which `instanceGranularity: 'auto'` resolves to `true`.
*/
export const AutoInstanceGranularityThreshold = 50_000_000;
/**
* Resolves the `instanceGranularity` param value to a boolean.
*/
export function resolveInstanceGranularity(value: InstanceGranularityValue, groupCount: number, instanceCount: number): boolean {
if (value === 'auto') return groupCount * instanceCount > AutoInstanceGranularityThreshold;
return value;
}
//
export namespace BaseGeometry {
export const MaterialCategory: PD.Info = { category: 'Material' };
export const ShadingCategory: PD.Info = { category: 'Shading' };
@@ -88,7 +107,7 @@ export namespace BaseGeometry {
clip: PD.Group(Clip.Params),
emissive: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }),
density: PD.Numeric(0.2, { min: 0, max: 1, step: 0.01 }, { description: 'Density value to estimate object thickness.' }),
instanceGranularity: PD.Boolean(false, { description: 'Use instance granularity for marker, transparency, clipping, overpaint, substance data to save memory.' }),
instanceGranularity: PD.Select<InstanceGranularityValue>('auto', InstanceGranularityOptions, { description: 'Use instance granularity for marker, transparency, clipping, overpaint, substance data to save memory. When set to `auto`, granularity is enabled if `groupCount * instanceCount` exceeds `AutoInstanceGranularityThreshold`.' }),
lod: PD.Vec3(Vec3(), undefined, { ...CullingLodCategory, description: 'Level of detail.', fieldLabels: { x: 'Min Distance', y: 'Max Distance', z: 'Overlap (Shader)' } }),
cellSize: PD.Numeric(200, { min: 0, max: 5000, step: 100 }, { ...CullingLodCategory, description: 'Instance grid cell size.' }),
batchSize: PD.Numeric(2000, { min: 0, max: 50000, step: 500 }, { ...CullingLodCategory, description: 'Instance grid batch size.' }),
@@ -130,7 +149,7 @@ export namespace BaseGeometry {
uClipObjectScale: ValueCell.create(clip.objects.scale),
uClipObjectTransform: ValueCell.create(clip.objects.transform),
instanceGranularity: ValueCell.create(props.instanceGranularity),
instanceGranularity: ValueCell.create(resolveInstanceGranularity(props.instanceGranularity, counts.groupCount, counts.instanceCount)),
uLod: ValueCell.create(Vec4.create(props.lod[0], props.lod[1], props.lod[2], 0)),
};
}
@@ -153,7 +172,7 @@ export namespace BaseGeometry {
ValueCell.update(values.uClipObjectScale, clip.objects.scale);
ValueCell.update(values.uClipObjectTransform, clip.objects.transform);
ValueCell.updateIfChanged(values.instanceGranularity, props.instanceGranularity);
ValueCell.updateIfChanged(values.instanceGranularity, resolveInstanceGranularity(props.instanceGranularity, values.uGroupCount.ref.value, values.instanceCount.ref.value));
ValueCell.update(values.uLod, Vec4.set(values.uLod.ref.value, props.lod[0], props.lod[1], props.lod[2], 0));
}

View File

@@ -19,7 +19,7 @@ import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } fr
import { Sphere3D } from '../../../mol-math/geometry';
import { Theme } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -225,7 +225,7 @@ export namespace Cylinders {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -17,7 +17,7 @@ import { ValueCell } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { Box } from '../../primitive/box';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createColors } from '../color-data';
import { GeometryUtils } from '../geometry';
import { createMarkers } from '../marker-data';
@@ -228,7 +228,7 @@ export namespace DirectVolume {
const positionIt = createPositionIterator(directVolume, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -14,7 +14,7 @@ import { Theme } from '../../../mol-theme/theme';
import { ValueCell } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createColors } from '../color-data';
import { GeometryUtils } from '../geometry';
import { createMarkers } from '../marker-data';
@@ -201,7 +201,7 @@ namespace Image {
const positionIt = createPositionIterator(image, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -21,7 +21,7 @@ import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } fr
import { Sphere3D } from '../../../mol-math/geometry';
import { Theme } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -232,7 +232,7 @@ export namespace Lines {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -20,7 +20,7 @@ import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } fr
import { Theme } from '../../../mol-theme/theme';
import { MeshValues } from '../../../mol-gl/renderable/mesh';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { createEmptyClipping } from '../clipping-data';
@@ -684,7 +684,7 @@ export namespace Mesh {
const positionIt = createPositionIterator(mesh, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -20,7 +20,7 @@ import { Theme } from '../../../mol-theme/theme';
import { PointsValues } from '../../../mol-gl/renderable/points';
import { RenderableState } from '../../../mol-gl/renderable';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -178,7 +178,7 @@ export namespace Points {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -17,7 +17,7 @@ import { TextureImage, calculateInvariantBoundingSphere, calculateTransformBound
import { Sphere3D } from '../../../mol-math/geometry';
import { createSizes, getMaxSize } from '../size-data';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -249,6 +249,33 @@ export namespace Spheres {
return lodLevels.map(l => getAdjustedStride(l, sizeFactor)).reverse();
}
export const LodLevelsPresets: { [key in 'performance' | 'balanced' | 'quality' | 'ultra']: LodLevels } = {
performance: [
{ minDistance: 1, maxDistance: 300, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 300, maxDistance: 2000, overlap: 0, stride: 40, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 150, scaleBias: 3 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 300, scaleBias: 2.5 },
],
balanced: [
{ minDistance: 1, maxDistance: 500, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 500, maxDistance: 2000, overlap: 0, stride: 15, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 70, scaleBias: 2.7 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.5 },
],
quality: [
{ minDistance: 1, maxDistance: 1000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 1000, maxDistance: 4000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 4000, maxDistance: 10000, overlap: 0, stride: 50, scaleBias: 2.7 },
{ minDistance: 10000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.3 },
],
ultra: [
{ minDistance: 1, maxDistance: 5000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 5000, maxDistance: 10000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 10000, maxDistance: 30000, overlap: 0, stride: 50, scaleBias: 2.5 },
{ minDistance: 30000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2 },
],
};
export const Params = {
...BaseGeometry.Params,
sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }),
@@ -273,7 +300,8 @@ export namespace Spheres {
scaleBias: PD.Numeric(3, { min: 0.1, max: 10, step: 0.1 }),
}, o => `${o.stride}`, {
...BaseGeometry.CullingLodCategory,
defaultValue: [] as LodLevels
defaultValue: [] as LodLevels,
presets: Object.entries(LodLevelsPresets).map(([k, v]) => [v, k])
})
};
export type Params = typeof Params
@@ -314,7 +342,7 @@ export namespace Spheres {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -25,7 +25,7 @@ import { FontAtlasParams } from './font-atlas';
import { RenderableState } from '../../../mol-gl/renderable';
import { clamp } from '../../../mol-math/interpolate';
import { createRenderObject as _createRenderObject } from '../../../mol-gl/render-object';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -219,7 +219,7 @@ export namespace Text {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -15,7 +15,7 @@ import { createMarkers } from '../marker-data';
import { GeometryUtils } from '../geometry';
import { Theme } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { TextureMeshValues } from '../../../mol-gl/renderable/texture-mesh';
@@ -203,7 +203,7 @@ export namespace TextureMesh {
const positionIt = Utils.createPositionIterator(textureMesh, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -78,7 +78,7 @@ export const apply_light_color = `
}
#pragma unroll_loop_end
outgoingLight += physicalMaterial.diffuseColor * luminance(uAmbientColor);
outgoingLight += physicalMaterial.diffuseColor * uAmbientColor;
#else
ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));

View File

@@ -133,6 +133,7 @@ export enum InteractionType {
Hydrophobic = 6,
MetalCoordination = 7,
WeakHydrogenBond = 8,
WaterBridge = 9,
}
export function interactionTypeLabel(type: InteractionType): string {
@@ -153,6 +154,8 @@ export function interactionTypeLabel(type: InteractionType): string {
return 'Pi Stacking';
case InteractionType.WeakHydrogenBond:
return 'Weak Hydrogen Bond';
case InteractionType.WaterBridge:
return 'Water Bridge';
case InteractionType.Unknown:
return 'Unknown Interaction';
}

View File

@@ -20,7 +20,7 @@ import { FeatureType, FeatureGroup, InteractionType } from './common';
import { ContactProvider } from './contacts';
import { MoleculeType, ProteinBackboneAtoms } from '../../../mol-model/structure/model/types';
const GeometryParams = {
export const GeometryParams = {
distanceMax: PD.Numeric(3.5, { min: 1, max: 5, step: 0.1 }),
backbone: PD.Boolean(true, { description: 'Include backbone-to-backbone hydrogen bonds' }),
accAngleDevMax: PD.Numeric(45, { min: 0, max: 180, step: 1 }, { description: 'Max deviation from ideal acceptor angle' }),
@@ -29,7 +29,7 @@ const GeometryParams = {
accOutOfPlaneAngleMax: PD.Numeric(90, { min: 0, max: 180, step: 1 }),
donOutOfPlaneAngleMax: PD.Numeric(45, { min: 0, max: 180, step: 1 }),
};
type GeometryParams = typeof GeometryParams
export type GeometryParams = typeof GeometryParams
type GeometryProps = PD.Values<GeometryParams>
const HydrogenBondsParams = {
@@ -208,7 +208,7 @@ function isWeakHydrogenBond(ti: FeatureType, tj: FeatureType) {
);
}
function getGeometryOptions(props: GeometryProps) {
export function getGeometryOptions(props: GeometryProps) {
return {
ignoreHydrogens: props.ignoreHydrogens,
includeBackbone: props.backbone,
@@ -218,7 +218,7 @@ function getGeometryOptions(props: GeometryProps) {
maxDonOutOfPlaneAngle: degToRad(props.donOutOfPlaneAngleMax),
};
}
type GeometryOptions = ReturnType<typeof getGeometryOptions>
export type GeometryOptions = ReturnType<typeof getGeometryOptions>
function getHydrogenBondsOptions(props: HydrogenBondsProps) {
return {
@@ -232,7 +232,7 @@ type HydrogenBondsOptions = ReturnType<typeof getHydrogenBondsOptions>
const deg120InRad = degToRad(120);
function checkGeometry(structure: Structure, don: Features.Info, acc: Features.Info, opts: GeometryOptions): true | undefined {
export function checkGeometry(structure: Structure, don: Features.Info, acc: Features.Info, opts: GeometryOptions): true | undefined {
const donIndex = don.members[don.offsets[don.feature]];
const accIndex = acc.members[acc.offsets[acc.feature]];

View File

@@ -1,20 +1,21 @@
/**
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 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>
*/
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { Structure, Unit, Bond } from '../../../mol-model/structure';
import { Structure, Unit, Bond, StructureElement } from '../../../mol-model/structure';
import { Features, FeaturesBuilder } from './features';
import { ValenceModelProvider } from '../valence-model';
import { InteractionsIntraContacts, InteractionsInterContacts, FeatureType, interactionTypeLabel } from './common';
import { InteractionsIntraContacts, InteractionsInterContacts, FeatureType, InteractionType, InteractionFlag, interactionTypeLabel } from './common';
import { IntraContactsBuilder, InterContactsBuilder } from './contacts-builder';
import { IntMap } from '../../../mol-data/int';
import { IntMap, OrderedSet } from '../../../mol-data/int';
import { addUnitContacts, ContactTester, addStructureContacts, ContactsParams, ContactsProps } from './contacts';
import { HalogenDonorProvider, HalogenAcceptorProvider, HalogenBondsProvider } from './halogen-bonds';
import { HydrogenDonorProvider, WeakHydrogenDonorProvider, HydrogenAcceptorProvider, HydrogenBondsProvider, WeakHydrogenBondsProvider } from './hydrogen-bonds';
import { WaterBridgesProvider } from './water-bridges';
import { NegativChargeProvider, PositiveChargeProvider, AromaticRingProvider, IonicProvider, PiStackingProvider, CationPiProvider } from './charged';
import { HydrophobicAtomProvider, HydrophobicProvider } from './hydrophobic';
import { SetUtils } from '../../../mol-util/set';
@@ -25,10 +26,26 @@ import { DataLocation } from '../../../mol-model/location';
import { CentroidHelper } from '../../../mol-math/geometry/centroid-helper';
import { Sphere3D } from '../../../mol-math/geometry';
import { DataLoci } from '../../../mol-model/loci';
import { bondLabel, LabelGranularity } from '../../../mol-theme/label';
import { bondLabel, bundleLabel, LabelGranularity } from '../../../mol-theme/label';
import { ObjectKeys } from '../../../mol-util/type-helpers';
export { Interactions };
export { Interactions, Bridges };
export type { BridgeContact, BridgeContacts };
interface BridgeContact {
readonly unitA: number
readonly indexA: Features.FeatureIndex
readonly unitB: number
readonly indexB: Features.FeatureIndex
/** mediator unit id */
readonly unitM: number
/** mediator feature facing endpoint A */
readonly indexMA: Features.FeatureIndex
/** mediator feature facing endpoint B */
readonly indexMB: Features.FeatureIndex
props: { type: InteractionType, flag: InteractionFlag }
}
type BridgeContacts = ReadonlyArray<BridgeContact>
interface Interactions {
/** Features of each unit */
@@ -37,6 +54,8 @@ interface Interactions {
unitsContacts: IntMap<InteractionsIntraContacts>
/** Interactions between units */
contacts: InteractionsInterContacts
/** Bridge-mediated interactions covering the whole structure */
bridges: BridgeContacts
}
namespace Interactions {
@@ -129,6 +148,93 @@ namespace Interactions {
}
}
namespace Bridges {
export interface Data {
readonly structure: Structure
readonly bridges: BridgeContacts
readonly unitsFeatures: IntMap<Features>
}
export interface Element { bridgeIndex: number }
export interface Location extends DataLocation<Data, Element> {}
export function Location(data: Data, bridgeIndex = 0): Location {
return DataLocation('bridges', data, { bridgeIndex });
}
export function isLocation(x: any): x is Location {
return !!x && x.kind === 'data-location' && x.tag === 'bridges';
}
export interface Loci extends DataLoci<Data, Element> {}
export function Loci(data: Data, elements: ReadonlyArray<Element>): Loci {
return DataLoci('bridges', data, elements,
bs => getBoundingSphere(data, elements, bs),
() => getLabel(data, elements));
}
export function isLoci(x: any): x is Loci {
return !!x && x.kind === 'data-loci' && x.tag === 'bridges';
}
function getLabel(data: Data, elements: ReadonlyArray<Element>): string {
const e = elements[0];
if (e === undefined) return '';
const { structure, bridges, unitsFeatures } = data;
const bridge = bridges[e.bridgeIndex];
const uA = structure.unitMap.get(bridge.unitA) as Unit.Atomic;
const fA = unitsFeatures.get(bridge.unitA);
const uM = structure.unitMap.get(bridge.unitM) as Unit.Atomic;
const fM = unitsFeatures.get(bridge.unitM);
const uB = structure.unitMap.get(bridge.unitB) as Unit.Atomic;
const fB = unitsFeatures.get(bridge.unitB);
const options = { granularity: 'element' as LabelGranularity };
if (fA.offsets[bridge.indexA + 1] - fA.offsets[bridge.indexA] > 1 ||
fB.offsets[bridge.indexB + 1] - fB.offsets[bridge.indexB] > 1) {
options.granularity = 'residue';
}
return [
interactionTypeLabel(bridge.props.type),
bundleLabel({ loci: [
StructureElement.Loci(structure, [{ unit: uA, indices: OrderedSet.ofSingleton(fA.members[fA.offsets[bridge.indexA]] as StructureElement.UnitIndex) }]),
StructureElement.Loci(structure, [{ unit: uM, indices: OrderedSet.ofSingleton(fM.members[fM.offsets[bridge.indexMA]] as StructureElement.UnitIndex) }]),
StructureElement.Loci(structure, [{ unit: uB, indices: OrderedSet.ofSingleton(fB.members[fB.offsets[bridge.indexB]] as StructureElement.UnitIndex) }]),
] }, options),
].join('</br>');
}
function getBoundingSphere(data: Data, elements: ReadonlyArray<Element>, boundingSphere: Sphere3D) {
return CentroidHelper.fromPairProvider(elements.length * 2, (i, pA, pB) => {
const bridge = data.bridges[elements[i >> 1].bridgeIndex];
const uA = data.structure.unitMap.get(bridge.unitA) as Unit.Atomic;
const fA = data.unitsFeatures.get(bridge.unitA);
const uM = data.structure.unitMap.get(bridge.unitM) as Unit.Atomic;
const fM = data.unitsFeatures.get(bridge.unitM);
const uB = data.structure.unitMap.get(bridge.unitB) as Unit.Atomic;
const fB = data.unitsFeatures.get(bridge.unitB);
const aIdx = fA.members[fA.offsets[bridge.indexA]];
const mIdx = fM.members[fM.offsets[bridge.indexMA]];
const bIdx = fB.members[fB.offsets[bridge.indexB]];
if ((i & 1) === 0) {
uA.conformation.position(uA.elements[aIdx], pA);
uM.conformation.position(uM.elements[mIdx], pB);
} else {
uM.conformation.position(uM.elements[mIdx], pA);
uB.conformation.position(uB.elements[bIdx], pB);
}
}, boundingSphere);
}
}
const FeatureProviders = [
HydrogenDonorProvider, WeakHydrogenDonorProvider, HydrogenAcceptorProvider,
NegativChargeProvider, PositiveChargeProvider, AromaticRingProvider,
@@ -174,8 +280,30 @@ export const ContactProviderParams = getProvidersParams([
// 'weak-hydrogen-bonds',
]);
const BridgeProviders = {
'water-bridges': WaterBridgesProvider,
};
type BridgeProviders = typeof BridgeProviders
function getBridgeProviderParams(defaultOn: string[] = []) {
const params: { [k in keyof BridgeProviders]: PD.Mapped<PD.NamedParamUnion<{
on: PD.Group<BridgeProviders[k]['params']>
off: PD.Group<{}>
}>> } = Object.create(null);
Object.keys(BridgeProviders).forEach(k => {
(params as any)[k] = PD.MappedStatic(defaultOn.includes(k) ? 'on' : 'off', {
on: PD.Group(BridgeProviders[k as keyof BridgeProviders].params),
off: PD.Group({})
}, { cycle: true });
});
return params;
}
export const BridgeProviderParams = getBridgeProviderParams([]);
export const InteractionsParams = {
providers: PD.Group(ContactProviderParams, { isFlat: true }),
bridges: PD.Group(BridgeProviderParams, { isFlat: true }),
contacts: PD.Group(ContactsParams, { label: 'Advanced Options' }),
};
export type InteractionsParams = typeof InteractionsParams
@@ -202,6 +330,9 @@ export async function computeInteractions(ctx: CustomProperty.Context, structure
const requiredFeatures = new Set<FeatureType>();
contactTesters.forEach(l => SetUtils.add(requiredFeatures, l.requiredFeatures));
ObjectKeys(BridgeProviders).forEach(k => {
if (p.bridges[k].name === 'on') SetUtils.add(requiredFeatures, BridgeProviders[k].requiredFeatures);
});
const featureProviders = FeatureProviders.filter(f => SetUtils.areIntersecting(requiredFeatures, f.types));
const unitsFeatures = IntMap.Mutable<Features>();
@@ -228,8 +359,9 @@ export async function computeInteractions(ctx: CustomProperty.Context, structure
}
const contacts = findInterUnitContacts(structure, unitsFeatures, contactTesters, p.contacts, options);
const bridges = findBridges(structure, unitsFeatures, p.bridges);
const interactions = { unitsFeatures, unitsContacts, contacts, bridges };
const interactions = { unitsFeatures, unitsContacts, contacts };
refineInteractions(structure, interactions);
return interactions;
}
@@ -260,6 +392,19 @@ function findIntraUnitContacts(structure: Structure, unit: Unit, features: Featu
return builder.getContacts();
}
function findBridges(structure: Structure, unitsFeatures: IntMap<Features>, props: PD.Values<typeof BridgeProviderParams>): BridgeContacts {
const bridges: BridgeContact[] = [];
ObjectKeys(BridgeProviders).forEach(k => {
const { name, params } = props[k];
if (name === 'on') {
for (const b of BridgeProviders[k].find(structure, unitsFeatures, params as any)) bridges.push(b);
}
});
return bridges;
}
function findInterUnitContacts(structure: Structure, unitsFeatures: IntMap<Features>, contactTesters: ReadonlyArray<ContactTester>, props: ContactsProps, options?: ComputeInterctionsOptions) {
const builder = InterContactsBuilder.create();

View File

@@ -1,15 +1,17 @@
/**
* Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
*
* based in part on NGL (https://github.com/arose/ngl)
*/
import { Interactions } from './interactions';
import { InteractionType, InteractionFlag, InteractionsIntraContacts, FeatureType, InteractionsInterContacts } from './common';
import { Unit, Structure } from '../../../mol-model/structure';
import { Unit, Structure, StructureElement } from '../../../mol-model/structure';
import { Features } from './features';
import { cantorPairing } from '../../../mol-data/util/hash-functions';
interface ContactRefiner {
isApplicable: (type: InteractionType) => boolean
@@ -27,6 +29,7 @@ export function refineInteractions(structure: Structure, interactions: Interacti
saltBridgeRefiner(structure, interactions),
piStackingRefiner(structure, interactions),
metalCoordinationRefiner(structure, interactions),
waterBridgeRefiner(structure, interactions),
];
for (let i = 0, il = contacts.edgeCount; i < il; ++i) {
@@ -278,4 +281,117 @@ function metalCoordinationRefiner(structure: Structure, interactions: Interactio
filterIntra([InteractionType.MetalCoordination], index, infoA, infoB, interactions.unitsContacts.get(infoA.unit.id));
}
};
}
function waterBridgeRefiner(_structure: Structure, interactions: Interactions): ContactRefiner {
const { contacts, bridges, unitsFeatures } = interactions;
type AtomKey = number;
type AtomPairSet = Map<AtomKey, Set<AtomKey>>;
function atomKey(unitId: number, atomIndex: StructureElement.UnitIndex): AtomKey {
return cantorPairing(unitId, atomIndex);
}
function featureMember(features: Features, featureIndex: Features.FeatureIndex): StructureElement.UnitIndex {
return features.members[features.offsets[featureIndex]] as StructureElement.UnitIndex;
}
function addAtomPair(
set: AtomPairSet,
unitA: number,
atomA: StructureElement.UnitIndex,
unitB: number,
atomB: StructureElement.UnitIndex
) {
const a = atomKey(unitA, atomA);
const b = atomKey(unitB, atomB);
let bs = set.get(a);
if (bs === undefined) {
bs = new Set();
set.set(a, bs);
}
bs.add(b);
let as = set.get(b);
if (as === undefined) {
as = new Set();
set.set(b, as);
}
as.add(a);
}
function hasAtomPair(
set: AtomPairSet,
unitA: number,
atomA: StructureElement.UnitIndex,
unitB: number,
atomB: StructureElement.UnitIndex
): boolean {
return set.get(atomKey(unitA, atomA))?.has(atomKey(unitB, atomB)) === true;
}
function hasInfoPair(set: AtomPairSet, infoA: Features.Info, infoB: Features.Info): boolean {
const { offsets: offsetsA, members: membersA, feature: featureA } = infoA;
const { offsets: offsetsB, members: membersB, feature: featureB } = infoB;
for (let i = offsetsA[featureA], il = offsetsA[featureA + 1]; i < il; ++i) {
const a = membersA[i] as StructureElement.UnitIndex;
for (let j = offsetsB[featureB], jl = offsetsB[featureB + 1]; j < jl; ++j) {
const b = membersB[j] as StructureElement.UnitIndex;
if (hasAtomPair(set, infoA.unit.id, a, infoB.unit.id, b)) return true;
}
}
return false;
}
const bridgeLegs: AtomPairSet = new Map();
for (const wb of bridges) {
if (wb.props.type !== InteractionType.WaterBridge) continue;
const fA = unitsFeatures.get(wb.unitA);
const fM = unitsFeatures.get(wb.unitM);
const fB = unitsFeatures.get(wb.unitB);
if (!fA || !fM || !fB) continue;
const atomA = featureMember(fA, wb.indexA);
const atomMA = featureMember(fM, wb.indexMA);
const atomMB = featureMember(fM, wb.indexMB);
const atomB = featureMember(fB, wb.indexB);
// donor atom ↔ water oxygen
addAtomPair(bridgeLegs, wb.unitA, atomA, wb.unitM, atomMA);
// water oxygen ↔ acceptor atom
addAtomPair(bridgeLegs, wb.unitM, atomMB, wb.unitB, atomB);
}
let intraContacts: InteractionsIntraContacts | undefined;
return {
isApplicable: (type: InteractionType) => {
return bridgeLegs.size > 0 && type === InteractionType.HydrogenBond;
},
handleInterContact: (index: number, infoA: Features.Info, infoB: Features.Info) => {
if (hasInfoPair(bridgeLegs, infoA, infoB)) {
contacts.edges[index].props.flag = InteractionFlag.Filtered;
}
},
startUnit: (_unit: Unit.Atomic, contacts: InteractionsIntraContacts) => {
intraContacts = contacts;
},
handleIntraContact: (index: number, infoA: Features.Info, infoB: Features.Info) => {
if (!intraContacts) return;
if (hasInfoPair(bridgeLegs, infoA, infoB)) {
intraContacts.edgeProps.flag[index] = InteractionFlag.Filtered;
}
},
};
}

View File

@@ -0,0 +1,331 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
*/
import { Structure, Unit, StructureElement } from '../../../mol-model/structure';
import { IntMap } from '../../../mol-data/int';
import { Vec3 } from '../../../mol-math/linear-algebra';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { MoleculeType, NucleicBackboneAtoms, ProteinBackboneAtoms } from '../../../mol-model/structure/model/types';
import { StructureLookup3DResultContext } from '../../../mol-model/structure/structure/util/lookup3d';
import { Features } from './features';
import { FeatureType, InteractionType, InteractionFlag } from './common';
import { GeometryOptions, checkGeometry } from './hydrogen-bonds';
import { degToRad } from '../../../mol-math/misc';
import { cantorPairing } from '../../../mol-data/util/hash-functions';
export type { WaterBridgeContact, WaterBridgeContacts };
interface WaterBridgeContact {
/** non-water donor unit id */
readonly unitA: number
/** donor feature index in unitA */
readonly indexA: Features.FeatureIndex
/** non-water acceptor unit id */
readonly unitB: number
/** acceptor feature index in unitB */
readonly indexB: Features.FeatureIndex
/** bridging water unit id */
readonly unitM: number
/** water oxygen as HydrogenAcceptor (leg: donor → water) */
readonly indexMA: Features.FeatureIndex
/** water oxygen as HydrogenDonor (leg: water → acceptor) */
readonly indexMB: Features.FeatureIndex
props: { type: InteractionType.WaterBridge, flag: InteractionFlag }
}
type WaterBridgeContacts = ReadonlyArray<WaterBridgeContact>;
export const WaterBridgesParams = {
backbone: PD.Boolean(true, { description: 'Include backbone hydrogen bonds' }),
ignoreHydrogens: PD.Boolean(true, { description: 'Ignore explicit hydrogens in geometric constraints' }),
legDistMin: PD.Numeric(2.5, { min: 1, max: 4, step: 0.1 }, { description: 'Minimum leg distance (Å)' }),
legDistMax: PD.Numeric(4.1, { min: 1, max: 6, step: 0.1 }, { description: 'Maximum leg distance (Å)' }),
donAngleDevMax: PD.Numeric(80, { min: 0, max: 180, step: 1 }, { description: 'Max deviation from ideal donor angle' }),
accAngleDevMax: PD.Numeric(50, { min: 0, max: 180, step: 1 }, { description: 'Max deviation from ideal acceptor angle' }),
donOutOfPlaneAngleMax: PD.Numeric(45, { min: 0, max: 180, step: 1 }),
accOutOfPlaneAngleMax: PD.Numeric(90, { min: 0, max: 180, step: 1 }),
omegaMin: PD.Numeric(71, { min: 0, max: 180, step: 1 }, { description: 'Minimum AWB angle (°)' }),
omegaMax: PD.Numeric(140, { min: 0, max: 180, step: 1 }, { description: 'Maximum AWB angle (°)' }),
};
export type WaterBridgesParams = typeof WaterBridgesParams;
export type WaterBridgesProps = PD.Values<WaterBridgesParams>;
export const WaterBridgesProvider = {
requiredFeatures: new Set([FeatureType.HydrogenDonor, FeatureType.HydrogenAcceptor]),
params: WaterBridgesParams,
find: findWaterBridgeContacts,
};
function isWater(unit: Unit.Atomic, index: StructureElement.UnitIndex): boolean {
return unit.model.atomicHierarchy.derived.residue.moleculeType[
unit.residueIndex[unit.elements[index]]
] === MoleculeType.Water;
}
function isBackboneAtom(unit: Unit.Atomic, index: StructureElement.UnitIndex): boolean {
const element = unit.elements[index];
const moleculeType = unit.model.atomicHierarchy.derived.residue.moleculeType[unit.residueIndex[element]];
if (moleculeType !== MoleculeType.Protein && moleculeType !== MoleculeType.RNA && moleculeType !== MoleculeType.DNA) {
return false;
}
const atomId = unit.model.atomicHierarchy.atoms.label_atom_id.value(element);
if (moleculeType === MoleculeType.Protein) {
return ProteinBackboneAtoms.has(atomId);
}
return NucleicBackboneAtoms.has(atomId);
}
const _lookupCtx = StructureLookup3DResultContext();
type Candidate = {
unit: Unit.Atomic
featureIdx: Features.FeatureIndex
memberIdx: StructureElement.UnitIndex
x: number
y: number
z: number
distSq: number
};
type FeatureKey = number;
function featureKey(unitId: number, featureIndex: Features.FeatureIndex): FeatureKey {
return cantorPairing(unitId, featureIndex);
}
type BestBridge = { contact: WaterBridgeContact; combinedDistSq: number };
type BestBridgeMap = Map<FeatureKey, Map<FeatureKey, BestBridge>>;
function getBestBridge(best: BestBridgeMap, donorKey: FeatureKey, acceptorKey: FeatureKey): BestBridge | undefined {
return best.get(donorKey)?.get(acceptorKey);
}
function setBestBridge(best: BestBridgeMap, donorKey: FeatureKey, acceptorKey: FeatureKey, value: BestBridge) {
let acceptors = best.get(donorKey);
if (acceptors === undefined) {
acceptors = new Map();
best.set(donorKey, acceptors);
}
acceptors.set(acceptorKey, value);
}
function bestBridgeValues(best: BestBridgeMap): BestBridge[] {
const values: BestBridge[] = [];
for (const acceptors of best.values()) {
for (const value of acceptors.values()) values.push(value);
}
return values;
}
function checkOmega(don: Candidate, posW: Vec3, acc: Candidate, cosOmegaMin: number, cosOmegaMax: number): boolean {
const ax = don.x - posW[0];
const ay = don.y - posW[1];
const az = don.z - posW[2];
const bx = acc.x - posW[0];
const by = acc.y - posW[1];
const bz = acc.z - posW[2];
const aLenSq = ax * ax + ay * ay + az * az;
const bLenSq = bx * bx + by * by + bz * bz;
if (aLenSq === 0 || bLenSq === 0) return false;
const cosOmega = (ax * bx + ay * by + az * bz) / Math.sqrt(aLenSq * bLenSq);
// cos decreases monotonically on [0, pi], so:
// omega >= omegaMin && omega <= omegaMax
// is equivalent to:
// cos(omega) <= cos(omegaMin) && cos(omega) >= cos(omegaMax)
return cosOmega <= cosOmegaMin && cosOmega >= cosOmegaMax;
}
export function findWaterBridgeContacts(
structure: Structure,
unitsFeatures: IntMap<Features>,
props: WaterBridgesProps
): WaterBridgeContacts {
const legOpts: GeometryOptions = {
ignoreHydrogens: props.ignoreHydrogens,
includeBackbone: props.backbone,
maxAccAngleDev: degToRad(props.accAngleDevMax),
maxDonAngleDev: degToRad(props.donAngleDevMax),
maxAccOutOfPlaneAngle: degToRad(props.accOutOfPlaneAngleMax),
maxDonOutOfPlaneAngle: degToRad(props.donOutOfPlaneAngleMax),
};
const legDistMinSq = props.legDistMin * props.legDistMin;
const legDistMaxSq = props.legDistMax * props.legDistMax;
const omegaMinRad = degToRad(props.omegaMin);
const omegaMaxRad = degToRad(props.omegaMax);
if (omegaMinRad > omegaMaxRad) return [];
const cosOmegaMin = Math.cos(omegaMinRad);
const cosOmegaMax = Math.cos(omegaMaxRad);
// Best bridge per unique donor/acceptor feature pair across all water molecules.
const best: BestBridgeMap = new Map();
const wPos = Vec3();
const candidatePos = Vec3();
for (const unitW of structure.units) {
if (!Unit.isAtomic(unitW)) continue;
const featW = unitsFeatures.get(unitW.id);
if (!featW || featW.count === 0) continue;
// Map each water-oxygen local index to its acceptor and donor feature indices.
const waterMap = new Map<StructureElement.UnitIndex, {
acc: Features.FeatureIndex | undefined,
don: Features.FeatureIndex | undefined
}>();
for (let fi = 0 as Features.FeatureIndex; fi < featW.count; fi++) {
const mi = featW.members[featW.offsets[fi]] as StructureElement.UnitIndex;
if (!isWater(unitW, mi)) continue;
const t = featW.types[fi];
if (t !== FeatureType.HydrogenAcceptor && t !== FeatureType.HydrogenDonor) continue;
let e = waterMap.get(mi);
if (!e) waterMap.set(mi, (e = { acc: undefined, don: undefined }));
if (t === FeatureType.HydrogenAcceptor) e.acc = fi;
else e.don = fi;
}
if (waterMap.size === 0) continue;
const infoWAcc = Features.Info(structure, unitW, featW);
const infoWDon = Features.Info(structure, unitW, featW);
for (const [waterAtomIdx, { acc: accFW, don: donFW }] of waterMap) {
if (accFW === undefined || donFW === undefined) continue;
unitW.conformation.position(unitW.elements[waterAtomIdx], wPos);
infoWAcc.feature = accFW;
infoWDon.feature = donFW;
const { count, indices, units: hitUnits } =
structure.lookup3d.find(wPos[0], wPos[1], wPos[2], props.legDistMax, _lookupCtx);
const donors: Candidate[] = [];
const acceptors: Candidate[] = [];
const donorKeys = new Set<FeatureKey>();
const acceptorKeys = new Set<FeatureKey>();
for (let r = 0; r < count; r++) {
const hitUnit = hitUnits[r];
if (!Unit.isAtomic(hitUnit)) continue;
const atomicUnit = hitUnit as Unit.Atomic;
const hitLocalIdx = indices[r] as StructureElement.UnitIndex;
// Only skip the water atom itself. Other atoms in the same unit can still be valid.
if (atomicUnit === unitW && hitLocalIdx === waterAtomIdx) continue;
if (isWater(atomicUnit, hitLocalIdx)) continue;
const hitFeat = unitsFeatures.get(atomicUnit.id);
if (!hitFeat || hitFeat.count === 0) continue;
const infoHit = Features.Info(structure, atomicUnit, hitFeat);
const { indices: fIdxs, offsets: fOff } = hitFeat.elementsIndex;
for (let k = fOff[hitLocalIdx], kl = fOff[hitLocalIdx + 1]; k < kl; k++) {
const fi = fIdxs[k] as Features.FeatureIndex;
const fType = hitFeat.types[fi];
if (fType !== FeatureType.HydrogenDonor && fType !== FeatureType.HydrogenAcceptor) continue;
const memberIdx = hitFeat.members[hitFeat.offsets[fi]] as StructureElement.UnitIndex;
if (!props.backbone && isBackboneAtom(atomicUnit, memberIdx)) continue;
atomicUnit.conformation.position(atomicUnit.elements[memberIdx], candidatePos);
const distSq = Vec3.squaredDistance(candidatePos, wPos);
if (distSq < legDistMinSq || distSq > legDistMaxSq) continue;
infoHit.feature = fi;
if (fType === FeatureType.HydrogenDonor) {
const key = featureKey(atomicUnit.id, fi);
if (donorKeys.has(key)) continue;
if (checkGeometry(structure, infoHit, infoWAcc, legOpts)) {
donorKeys.add(key);
donors.push({
unit: atomicUnit,
featureIdx: fi,
memberIdx,
x: candidatePos[0],
y: candidatePos[1],
z: candidatePos[2],
distSq,
});
}
} else {
const key = featureKey(atomicUnit.id, fi);
if (acceptorKeys.has(key)) continue;
if (checkGeometry(structure, infoWDon, infoHit, legOpts)) {
acceptorKeys.add(key);
acceptors.push({
unit: atomicUnit,
featureIdx: fi,
memberIdx,
x: candidatePos[0],
y: candidatePos[1],
z: candidatePos[2],
distSq,
});
}
}
}
}
for (const don of donors) {
for (const acc of acceptors) {
// Reject bridges where donor and acceptor are the same physical atom
// represented by different feature indices.
if (don.unit === acc.unit && don.memberIdx === acc.memberIdx) continue;
if (!checkOmega(don, wPos, acc, cosOmegaMin, cosOmegaMax)) continue;
const combinedDistSq = don.distSq + acc.distSq;
const donorKey = featureKey(don.unit.id, don.featureIdx);
const acceptorKey = featureKey(acc.unit.id, acc.featureIdx);
const existing = getBestBridge(best, donorKey, acceptorKey);
if (!existing || combinedDistSq < existing.combinedDistSq) {
setBestBridge(best, donorKey, acceptorKey, {
contact: {
unitA: don.unit.id,
indexA: don.featureIdx,
unitB: acc.unit.id,
indexB: acc.featureIdx,
unitM: unitW.id,
indexMA: accFW,
indexMB: donFW,
props: { type: InteractionType.WaterBridge, flag: InteractionFlag.None },
},
combinedDistSq,
});
}
}
}
}
}
return bestBridgeValues(best).map(e => e.contact);
}

View File

@@ -0,0 +1,400 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
*/
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { VisualContext } from '../../../mol-repr/visual';
import { Structure, StructureElement, Unit } from '../../../mol-model/structure';
import { Theme } from '../../../mol-theme/theme';
import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
import { Vec3 } from '../../../mol-math/linear-algebra';
import { createLinkCylinderMesh, LinkCylinderParams, LinkStyle } from '../../../mol-repr/structure/visual/util/link';
import { ComplexMeshParams, ComplexVisual, ComplexMeshVisual } from '../../../mol-repr/structure/complex-visual';
import { VisualUpdateState } from '../../../mol-repr/util';
import { PickingId } from '../../../mol-geo/geometry/picking';
import { EmptyLoci, Loci } from '../../../mol-model/loci';
import { NullLocation } from '../../../mol-model/location';
import { Interval, OrderedSet } from '../../../mol-data/int';
import { InteractionsProvider } from '../interactions';
import { LocationIterator } from '../../../mol-geo/util/location-iterator';
import { BridgeContacts, Bridges } from '../interactions/interactions';
import { Sphere3D } from '../../../mol-math/geometry';
import { InteractionsSharedParams } from './shared';
import { Features } from '../interactions/features';
type CanonicalLegIndices = {
endpointA: Int32Array
endpointB: Int32Array
};
const CanonicalLegIndicesCache = new WeakMap<BridgeContacts, CanonicalLegIndices>();
function getCanonicalLegIndices(bridges: BridgeContacts): CanonicalLegIndices {
const cached = CanonicalLegIndicesCache.get(bridges);
if (cached) return cached;
const n = bridges.length;
const endpointA = new Int32Array(n);
const endpointB = new Int32Array(n);
const legA = new Map<string, number>();
const legB = new Map<string, number>();
for (let i = 0; i < n; i++) {
const b = bridges[i];
const kA = `${b.unitA}|${b.indexA}|${b.unitM}|${b.indexMA}`;
const kB = `${b.unitM}|${b.indexMB}|${b.unitB}|${b.indexB}`;
let ai = legA.get(kA);
if (ai === undefined) {
ai = i;
legA.set(kA, i);
}
endpointA[i] = ai;
let bi = legB.get(kB);
if (bi === undefined) {
bi = i;
legB.set(kB, i);
}
endpointB[i] = bi;
}
const indices = { endpointA, endpointB };
CanonicalLegIndicesCache.set(bridges, indices);
return indices;
}
function getFeatureMember(features: Features, featureIndex: Features.FeatureIndex): StructureElement.UnitIndex {
return features.members[features.offsets[featureIndex]] as StructureElement.UnitIndex;
}
function atomPosition(unit: Unit.Atomic, features: Features, featureIndex: Features.FeatureIndex, out: Vec3) {
const atomLocalIdx = getFeatureMember(features, featureIndex);
unit.conformation.position(unit.elements[atomLocalIdx], out);
}
function setFeatureLocation(
structure: Structure,
location: StructureElement.Location,
unitId: number,
features: Features,
featureIndex: Features.FeatureIndex
) {
const unit = structure.unitMap.get(unitId) as Unit.Atomic;
const atomLocalIdx = getFeatureMember(features, featureIndex);
location.unit = unit;
location.element = unit.elements[atomLocalIdx];
}
function applyLegA(
bridgeIndex: number,
bridgeCount: number,
canonical: CanonicalLegIndices,
apply: (interval: Interval) => boolean
) {
let changed = false;
const i = canonical.endpointA[bridgeIndex];
if (apply(Interval.ofSingleton(i))) changed = true;
if (apply(Interval.ofSingleton(i + bridgeCount))) changed = true;
return changed;
}
function applyLegB(
bridgeIndex: number,
bridgeCount: number,
canonical: CanonicalLegIndices,
apply: (interval: Interval) => boolean
) {
let changed = false;
const i = canonical.endpointB[bridgeIndex];
if (apply(Interval.ofSingleton(i + 2 * bridgeCount))) changed = true;
if (apply(Interval.ofSingleton(i + 3 * bridgeCount))) changed = true;
return changed;
}
function createBridgeCylinderMesh(ctx: VisualContext, structure: Structure, theme: Theme, props: PD.Values<BridgeParams>, mesh?: Mesh) {
if (!structure.hasAtomic) return Mesh.createEmpty(mesh);
const interactions = InteractionsProvider.get(structure).value;
if (!interactions) return Mesh.createEmpty(mesh);
const { bridges, unitsFeatures } = interactions;
const n = bridges.length;
if (!n) return Mesh.createEmpty(mesh);
const l = StructureElement.Location.create(structure);
const { sizeFactor } = props;
const canonical = getCanonicalLegIndices(bridges);
const builderProps = {
// Four half-cylinders per bridge; createLinkCylinderMesh draws the A-side half per call:
// [0, n): A→mediator, forward (A side)
// [n, 2n): A→mediator, backward (mediator side)
// [2n, 3n): mediator→B, forward (mediator side)
// [3n, 4n): mediator→B, backward (B side)
//
// When multiple bridges share the same physical leg, only the first
// occurrence is drawn; later ones map back to the canonical edge index.
linkCount: 4 * n,
position: (posA: Vec3, posB: Vec3, edgeIndex: number) => {
const b = bridges[edgeIndex % n];
const uM = structure.unitMap.get(b.unitM) as Unit.Atomic;
const fM = unitsFeatures.get(b.unitM);
const leg = Math.floor(edgeIndex / n);
if (leg === 0) {
const uA = structure.unitMap.get(b.unitA) as Unit.Atomic;
const fA = unitsFeatures.get(b.unitA);
atomPosition(uA, fA, b.indexA, posA);
atomPosition(uM, fM, b.indexMA, posB);
} else if (leg === 1) {
const uA = structure.unitMap.get(b.unitA) as Unit.Atomic;
const fA = unitsFeatures.get(b.unitA);
atomPosition(uM, fM, b.indexMA, posA);
atomPosition(uA, fA, b.indexA, posB);
} else if (leg === 2) {
const uB = structure.unitMap.get(b.unitB) as Unit.Atomic;
const fB = unitsFeatures.get(b.unitB);
atomPosition(uM, fM, b.indexMB, posA);
atomPosition(uB, fB, b.indexB, posB);
} else {
const uB = structure.unitMap.get(b.unitB) as Unit.Atomic;
const fB = unitsFeatures.get(b.unitB);
atomPosition(uB, fB, b.indexB, posA);
atomPosition(uM, fM, b.indexMB, posB);
}
},
ignore: (edgeIndex: number) => {
const bi = edgeIndex % n;
const leg = Math.floor(edgeIndex / n);
return leg <= 1
? canonical.endpointA[bi] !== bi
: canonical.endpointB[bi] !== bi;
},
style: (_edgeIndex: number) => LinkStyle.Dashed,
radius: (edgeIndex: number) => {
const b = bridges[edgeIndex % n];
const leg = Math.floor(edgeIndex / n);
const isLegA = leg <= 1;
if (isLegA) {
const fA = unitsFeatures.get(b.unitA);
const fM = unitsFeatures.get(b.unitM);
setFeatureLocation(structure, l, b.unitA, fA, b.indexA);
const sizeA = theme.size.size(l);
setFeatureLocation(structure, l, b.unitM, fM, b.indexMA);
const sizeM = theme.size.size(l);
return Math.min(sizeA, sizeM) * sizeFactor;
} else {
const fM = unitsFeatures.get(b.unitM);
const fB = unitsFeatures.get(b.unitB);
setFeatureLocation(structure, l, b.unitM, fM, b.indexMB);
const sizeM = theme.size.size(l);
setFeatureLocation(structure, l, b.unitB, fB, b.indexB);
const sizeB = theme.size.size(l);
return Math.min(sizeM, sizeB) * sizeFactor;
}
},
};
const { mesh: m, boundingSphere } = createLinkCylinderMesh(ctx, builderProps, props, mesh);
if (boundingSphere) {
m.setBoundingSphere(boundingSphere);
} else if (m.triangleCount > 0) {
const sphere = Sphere3D.expand(Sphere3D(), structure.boundary.sphere, sizeFactor);
m.setBoundingSphere(sphere);
}
return m;
}
export const BridgeParams = {
...ComplexMeshParams,
...LinkCylinderParams,
...InteractionsSharedParams,
};
export type BridgeParams = typeof BridgeParams
export function BridgeVisual(materialId: number): ComplexVisual<BridgeParams> {
return ComplexMeshVisual<BridgeParams>({
defaultProps: PD.getDefaultValues(BridgeParams),
createGeometry: createBridgeCylinderMesh,
createLocationIterator: createBridgeIterator,
getLoci: getBridgeLoci,
eachLocation: eachBridgeInteraction,
setUpdateState: (
state: VisualUpdateState,
newProps: PD.Values<BridgeParams>,
currentProps: PD.Values<BridgeParams>,
newTheme: Theme,
currentTheme: Theme,
newStructure: Structure,
_currentStructure: Structure
) => {
state.createGeometry = (
newProps.sizeFactor !== currentProps.sizeFactor ||
newProps.dashCount !== currentProps.dashCount ||
newProps.dashScale !== currentProps.dashScale ||
newProps.dashCap !== currentProps.dashCap ||
newProps.radialSegments !== currentProps.radialSegments ||
newTheme.size !== currentTheme.size
);
const interactionsHash = InteractionsProvider.get(newStructure).version;
if ((state.info.interactionsHash as number) !== interactionsHash) {
state.createGeometry = true;
state.updateTransform = true;
state.updateColor = true;
state.info.interactionsHash = interactionsHash;
}
}
}, materialId);
}
function getBridgeLoci(pickingId: PickingId, structure: Structure, id: number) {
const { objectId, groupId } = pickingId;
if (id !== objectId) return EmptyLoci;
const interactions = InteractionsProvider.get(structure).value;
if (!interactions) return EmptyLoci;
const { bridges, unitsFeatures } = interactions;
const n = bridges.length;
if (!n || groupId < 0 || groupId >= 4 * n) return EmptyLoci;
const bridgeIndex = groupId % n;
return Bridges.Loci({ structure, bridges, unitsFeatures }, [{ bridgeIndex }]);
}
const __unitMap = new Map<number, OrderedSet<StructureElement.UnitIndex>>();
function eachBridgeInteraction(loci: Loci, structure: Structure, apply: (interval: Interval) => boolean, _isMarking: boolean) {
let changed = false;
if (Bridges.isLoci(loci)) {
if (!Structure.areEquivalent(loci.data.structure, structure)) return false;
const interactions = InteractionsProvider.get(structure).value;
if (!interactions) return false;
const { bridges } = interactions;
const n = bridges.length;
if (!n) return false;
const canonical = getCanonicalLegIndices(bridges);
for (const e of loci.elements) {
if (e.bridgeIndex < 0 || e.bridgeIndex >= n) continue;
if (applyLegA(e.bridgeIndex, n, canonical, apply)) changed = true;
if (applyLegB(e.bridgeIndex, n, canonical, apply)) changed = true;
}
} else if (StructureElement.Loci.is(loci)) {
if (!Structure.areEquivalent(loci.structure, structure)) return false;
const interactions = InteractionsProvider.get(structure).value;
if (!interactions) return false;
const { bridges, unitsFeatures } = interactions;
const n = bridges.length;
if (!n) return false;
const canonical = getCanonicalLegIndices(bridges);
__unitMap.clear();
for (const e of loci.elements) {
__unitMap.set(e.unit.id, e.indices);
}
for (let i = 0; i < n; i++) {
const b = bridges[i];
const indicesA = __unitMap.get(b.unitA);
const indicesM = __unitMap.get(b.unitM);
const indicesB = __unitMap.get(b.unitB);
if (!indicesA && !indicesM && !indicesB) continue;
let hitA = false;
if (indicesA) {
const fA = unitsFeatures.get(b.unitA);
const mi = getFeatureMember(fA, b.indexA);
hitA = OrderedSet.has(indicesA, mi);
}
let hitM = false;
if (indicesM) {
const fM = unitsFeatures.get(b.unitM);
const miA = getFeatureMember(fM, b.indexMA);
const miB = getFeatureMember(fM, b.indexMB);
hitM = OrderedSet.has(indicesM, miA) || OrderedSet.has(indicesM, miB);
}
let hitB = false;
if (indicesB) {
const fB = unitsFeatures.get(b.unitB);
const mi = getFeatureMember(fB, b.indexB);
hitB = OrderedSet.has(indicesB, mi);
}
if (hitA || hitM) {
if (applyLegA(i, n, canonical, apply)) changed = true;
}
if (hitB || hitM) {
if (applyLegB(i, n, canonical, apply)) changed = true;
}
}
__unitMap.clear();
}
return changed;
}
function createBridgeIterator(structure: Structure): LocationIterator {
const interactions = InteractionsProvider.get(structure).value;
if (!interactions) return LocationIterator(0, 1, 1, () => NullLocation, true);
const { bridges, unitsFeatures } = interactions;
const n = bridges.length;
const groupCount = 4 * n;
const instanceCount = 1;
const data: Bridges.Data = { structure, bridges, unitsFeatures };
const location = Bridges.Location(data);
const { element } = location;
const getLocation = (groupIndex: number) => {
element.bridgeIndex = n === 0 ? 0 : groupIndex % n;
return location;
};
return LocationIterator(groupCount, instanceCount, 1, getLocation, true);
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -12,20 +12,23 @@ import { UnitsRepresentation, StructureRepresentation, StructureRepresentationSt
import { InteractionsIntraUnitParams, InteractionsIntraUnitVisual } from './interactions-intra-unit-cylinder';
import { InteractionsProvider } from '../interactions';
import { InteractionsInterUnitParams, InteractionsInterUnitVisual } from './interactions-inter-unit-cylinder';
import { BridgeParams, BridgeVisual } from './interactions-bridge-cylinder';
import { CustomProperty } from '../../common/custom-property';
import { getUnitKindsParam } from '../../../mol-repr/structure/params';
const InteractionsVisuals = {
'intra-unit': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InteractionsIntraUnitParams>) => UnitsRepresentation('Intra-unit interactions cylinder', ctx, getParams, InteractionsIntraUnitVisual),
'inter-unit': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InteractionsInterUnitParams>) => ComplexRepresentation('Inter-unit interactions cylinder', ctx, getParams, InteractionsInterUnitVisual),
'bridge': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, BridgeParams>) => ComplexRepresentation('Bridge cylinder', ctx, getParams, BridgeVisual),
};
export const InteractionsParams = {
...InteractionsIntraUnitParams,
...InteractionsInterUnitParams,
...BridgeParams,
unitKinds: getUnitKindsParam(['atomic']),
sizeFactor: PD.Numeric(0.2, { min: 0.01, max: 1, step: 0.01 }),
visuals: PD.MultiSelect(['intra-unit', 'inter-unit'], PD.objectToOptions(InteractionsVisuals)),
visuals: PD.MultiSelect(['intra-unit', 'inter-unit', 'bridge'], PD.objectToOptions(InteractionsVisuals)),
};
export type InteractionsParams = typeof InteractionsParams
export function getInteractionParams(ctx: ThemeRegistryContext, structure: Structure) {

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
*/
import { Location } from '../../../mol-model/location';
@@ -12,7 +13,7 @@ import { ThemeDataContext } from '../../../mol-theme/theme';
import { ColorTheme, LocationColor } from '../../../mol-theme/color';
import { InteractionType } from '../interactions/common';
import { TableLegend } from '../../../mol-util/legend';
import { Interactions } from '../interactions/interactions';
import { Interactions, Bridges } from '../interactions/interactions';
import { CustomProperty } from '../../common/custom-property';
import { hash2 } from '../../../mol-data/util';
import { ColorThemeCategory } from '../../../mol-theme/color/categories';
@@ -29,6 +30,7 @@ const InteractionTypeColors = ColorMap({
CationPi: 0xFF8000,
PiStacking: 0x8CB366,
WeakHydrogenBond: 0xC5DDEC,
WaterBridge: 0x00CCEE,
});
const InteractionTypeColorTable: [string, Color][] = [
@@ -40,6 +42,7 @@ const InteractionTypeColorTable: [string, Color][] = [
['Cation Pi', InteractionTypeColors.CationPi],
['Pi Stacking', InteractionTypeColors.PiStacking],
['Weak HydrogenBond', InteractionTypeColors.WeakHydrogenBond],
['Water Bridge', InteractionTypeColors.WaterBridge],
];
function typeColor(type: InteractionType): Color {
@@ -60,6 +63,8 @@ function typeColor(type: InteractionType): Color {
return InteractionTypeColors.PiStacking;
case InteractionType.WeakHydrogenBond:
return InteractionTypeColors.WeakHydrogenBond;
case InteractionType.WaterBridge:
return InteractionTypeColors.WaterBridge;
case InteractionType.Unknown:
return DefaultColor;
}
@@ -91,6 +96,9 @@ export function InteractionTypeColorTheme(ctx: ThemeDataContext, props: PD.Value
return typeColor(contacts.edges[idx].props.type);
}
}
if (Bridges.isLocation(location)) {
return typeColor(location.data.bridges[location.element.bridgeIndex].props.type);
}
return DefaultColor;
};
} else {

View File

@@ -167,9 +167,9 @@ namespace Loci {
} else if (loci.kind === 'data-loci') {
return loci.getBoundingSphere?.(boundingSphere);
} else if (loci.kind === 'volume-loci') {
return Volume.getBoundingSphere(loci.volume, boundingSphere);
return Volume.getBoundingSphere(loci.volume, loci.instances, boundingSphere);
} else if (loci.kind === 'isosurface-loci') {
return Volume.Isosurface.getBoundingSphere(loci.volume, loci.isoValue, boundingSphere);
return Volume.Isosurface.getBoundingSphere(loci.volume, loci.isoValue, loci.instances, boundingSphere);
} else if (loci.kind === 'cell-loci') {
return Volume.Cell.getBoundingSphere(loci.volume, loci.elements, boundingSphere);
} else if (loci.kind === 'segment-loci') {

View File

@@ -545,6 +545,12 @@ export function surroundingLigands({ query, radius, includeWater }: SurroundingL
continue;
}
// Water is handled exclusively by the `includeWater` 3D-lookup branch below.
// A single water pulled in via a struct_conn metalc/covale edge would
// otherwise match every other water in the chain (all share label_seq_id
// and label_comp_id) and leak the entire chain.
if (StructureProperties.entity.type(l) === 'water') continue;
residuesIt.setSegment(chainSegment);
while (residuesIt.hasNext) {
const residueSegment = residuesIt.move();

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 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>
@@ -95,10 +95,22 @@ namespace UnitRing {
Elements.SN, Elements.SB,
Elements.BI
] as ElementSymbol[]);
/**
* Elements that are sp3 (and therefore non-aromatic) when degree >= 4 with no pi bonds.
* Excludes O (never realistically reaches degree 4) and N (quaternary N can be aromatic,
* but is guarded by the hasPiBond check below).
*/
const Sp3RingCheckElements = new Set([
Elements.B, Elements.C, Elements.N,
Elements.SI, Elements.P, Elements.S,
Elements.GE, Elements.AS,
Elements.SN, Elements.SB,
Elements.BI
] as ElementSymbol[]);
const AromaticRingPlanarityThreshold = 0.05;
export function isAromatic(unit: Unit.Atomic, ring: UnitRing): boolean {
const { elements, bonds: { b, offset, edgeProps: { flags } } } = unit;
const { elements, bonds: { b, offset, edgeProps: { flags, order } } } = unit;
const { type_symbol, label_comp_id } = unit.model.atomicHierarchy.atoms;
// ignore Proline (can be flat because of bad geometry)
@@ -120,6 +132,25 @@ namespace UnitRing {
}
}
}
for (let i = 0, il = ring.length; i < il; ++i) {
const aI = ring[i];
const elem = type_symbol.value(elements[aI]);
if (!Sp3RingCheckElements.has(elem)) continue;
let degree = 0;
let hasPiBond = false;
for (let j = offset[aI], jl = offset[aI + 1]; j < jl; ++j) {
degree += 1;
const f = flags[j];
const o = order[j];
if (BondType.is(BondType.Flag.Aromatic, f) || o === 2 || o === 3) {
hasPiBond = true;
}
}
if (degree >= 4 && !hasPiBond) return false;
}
if (aromaticBondCount === 2 * ring.length) return true;
if (!hasAromaticRingElement) return false;
if (ring.length < 5) return false;

View File

@@ -68,6 +68,36 @@ namespace Grid {
return Sphere3D.fromDimensionsAndTransform(boundingSphere, dimensions, transform);
}
const _isoBbox = Box3D();
export function getIsosurfaceBoundingSphere(grid: Grid, isoValue: number, boundingSphere?: Sphere3D) {
const neg = isoValue < 0;
const c = [0, 0, 0];
const getCoords = grid.cells.space.getCoords;
const d = grid.cells.data;
const [xn, yn, zn] = grid.cells.space.dimensions;
let minx = xn - 1, miny = yn - 1, minz = zn - 1;
let maxx = 0, maxy = 0, maxz = 0;
for (let i = 0, il = d.length; i < il; ++i) {
if ((neg && d[i] <= isoValue) || (!neg && d[i] >= isoValue)) {
getCoords(i, c);
if (c[0] < minx) minx = c[0];
if (c[1] < miny) miny = c[1];
if (c[2] < minz) minz = c[2];
if (c[0] > maxx) maxx = c[0];
if (c[1] > maxy) maxy = c[1];
if (c[2] > maxz) maxz = c[2];
}
}
Vec3.set(_isoBbox.min, minx - 1, miny - 1, minz - 1);
Vec3.set(_isoBbox.max, maxx + 1, maxy + 1, maxz + 1);
const transform = Grid.getGridToCartesianTransform(grid);
Box3D.transform(_isoBbox, _isoBbox, transform);
return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), _isoBbox);
}
/**
* Compute histogram with given bin count.
* Cached on the Grid object.

View File

@@ -6,7 +6,7 @@
*/
import { Grid } from './grid';
import { OrderedSet } from '../../mol-data/int';
import { Interval, OrderedSet } from '../../mol-data/int';
import { Box3D, Sphere3D } from '../../mol-math/geometry';
import { Vec3, Mat4 } from '../../mol-math/linear-algebra';
import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
@@ -191,14 +191,14 @@ export namespace Volume {
export function isLociEmpty(loci: Loci) { return isEmpty(loci.volume) || OrderedSet.isEmpty(loci.instances); }
const boundaryHelper = new BoundaryHelper('98');
export function getBoundingSphere(volume: Volume, boundingSphere?: Sphere3D) {
export function getBoundingSphere(volume: Volume, instances: OrderedSet<InstanceIndex>, boundingSphere?: Sphere3D) {
const gs = Grid.getBoundingSphere(volume.grid);
if (!boundingSphere) boundingSphere = Sphere3D();
if (volume.instances.length === 0) return Sphere3D.copy(boundingSphere, gs);
if (OrderedSet.isEmpty(instances)) return Sphere3D.copy(boundingSphere, gs);
const spheres: Sphere3D[] = [];
for (let i = 0, il = volume.instances.length; i < il; ++i) {
const { transform } = volume.instances[i];
for (let i = 0, il = OrderedSet.size(instances); i < il; ++i) {
const { transform } = volume.instances[OrderedSet.getAt(instances, i)];
spheres.push(Sphere3D.transform(Sphere3D(), gs, transform));
}
@@ -220,35 +220,23 @@ export namespace Volume {
export function areLociEqual(a: Loci, b: Loci) { return a.volume === b.volume && Volume.IsoValue.areSame(a.isoValue, b.isoValue, a.volume.grid.stats) && OrderedSet.areEqual(a.instances, b.instances); }
export function isLociEmpty(loci: Loci) { return isEmpty(loci.volume) || OrderedSet.isEmpty(loci.instances); }
const bbox = Box3D();
export function getBoundingSphere(volume: Volume, isoValue: Volume.IsoValue, boundingSphere?: Sphere3D) {
const boundaryHelper = new BoundaryHelper('98');
export function getBoundingSphere(volume: Volume, isoValue: Volume.IsoValue, instances: OrderedSet<InstanceIndex>, boundingSphere?: Sphere3D) {
const value = Volume.IsoValue.toAbsolute(isoValue, volume.grid.stats).absoluteValue;
const neg = value < 0;
const gs = Grid.getIsosurfaceBoundingSphere(volume.grid, value);
const c = [0, 0, 0];
const getCoords = volume.grid.cells.space.getCoords;
const d = volume.grid.cells.data;
const [xn, yn, zn] = volume.grid.cells.space.dimensions;
if (OrderedSet.isEmpty(instances)) return Sphere3D.copy(boundingSphere || Sphere3D(), gs);
let minx = xn - 1, miny = yn - 1, minz = zn - 1;
let maxx = 0, maxy = 0, maxz = 0;
for (let i = 0, il = d.length; i < il; ++i) {
if ((neg && d[i] <= value) || (!neg && d[i] >= value)) {
getCoords(i, c);
if (c[0] < minx) minx = c[0];
if (c[1] < miny) miny = c[1];
if (c[2] < minz) minz = c[2];
if (c[0] > maxx) maxx = c[0];
if (c[1] > maxy) maxy = c[1];
if (c[2] > maxz) maxz = c[2];
}
const spheres: Sphere3D[] = [];
for (let i = 0, il = OrderedSet.size(instances); i < il; ++i) {
spheres.push(Sphere3D.transform(Sphere3D(), gs, volume.instances[OrderedSet.getAt(instances, i)].transform));
}
Vec3.set(bbox.min, minx - 1, miny - 1, minz - 1);
Vec3.set(bbox.max, maxx + 1, maxy + 1, maxz + 1);
const transform = Grid.getGridToCartesianTransform(volume.grid);
Box3D.transform(bbox, bbox, transform);
return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), bbox);
boundaryHelper.reset();
for (const s of spheres) boundaryHelper.includeSphere(s);
boundaryHelper.finishedIncludeStep();
for (const s of spheres) boundaryHelper.radiusSphere(s);
return boundaryHelper.getSphere(boundingSphere);
}
}
@@ -416,7 +404,7 @@ export namespace Volume {
}
return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), bbox);
} else {
return Volume.getBoundingSphere(volume, boundingSphere);
return Volume.getBoundingSphere(volume, Interval.ofLength(volume.instances.length as InstanceIndex), boundingSphere);
}
}

View File

@@ -1,8 +1,9 @@
/**
* Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Adam Midlik <midlik@gmail.com>
*/
import { PluginContext } from '../../mol-plugin/context';
@@ -76,7 +77,10 @@ const DownloadStructure = StateAction.build({
}, { isFlat: true, label: 'SWISS-MODEL', description: 'Loads the best homology model or experimental structure' }),
'alphafolddb': PD.Group({
provider: PD.Group({
id: PD.Text('Q8W3K0', { label: 'UniProtKB AC(s)', description: 'One or more comma/space separated ACs.' }),
id: PD.Text('Q8W3K0', {
label: 'ID(s)',
description: 'One or more comma/space separated IDs. Each ID can be either UniProt accession (e.g. Q14676, Q14676-2) or AlphaFoldDB model entity ID (e.g. AF-Q14676-F1, AF-Q14676-2-F1, AF-0000000066074510). Version suffixes (e.g. -v1) will be ignored and the newest model version will be downloaded.',
}),
encoding: PD.Select('bcif', PD.arrayToOptions(['cif', 'bcif'] as const)),
}, { pivot: 'id' }),
options
@@ -152,7 +156,11 @@ const DownloadStructure = StateAction.build({
case 'alphafolddb':
downloadParams = await getDownloadParams(src.params.provider.id,
async id => {
const url = `https://www.alphafold.ebi.ac.uk/api/prediction/${id.toUpperCase()}`;
// id = UniProt accession: Q14676, Q14676-4
// id = model entity ID: AF-Q14676-F1, AF-Q14676-4-F1, AF-0000000066074510
// id = model entity ID + version to be ignored: AF-Q14676-4-F1-v6, AF-0000000066074510-v1
const cleanId = id.replace(/-v\d+$/i, '').toUpperCase(); // Ignore version suffix (e.g. "-v6") because it is not a part of the ID, but displayed on AFDB page and people often copy-paste it
const url = `https://www.alphafold.ebi.ac.uk/api/prediction/${cleanId}`;
const info = await plugin.runTask(plugin.fetch({ url, type: 'json' }));
if (Array.isArray(info) && info.length > 0) {
const prop = src.params.provider.encoding === 'bcif' ? 'bcifUrl' : 'cifUrl';

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2022-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -12,7 +12,7 @@ import { degToRad } from '../../../mol-math/misc';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { PluginStateAnimation } from '../model';
const _dir = Vec3(), _axis = Vec3(), _rot = Quat();
const _dir = Vec3(), _axis = Vec3(), _rot = Quat(), _up = Vec3(), _side = Vec3();
type State = { snapshot: Camera.Snapshot };
@@ -24,6 +24,7 @@ export const AnimateCameraRock = PluginStateAnimation.create({
durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
speed: PD.Numeric(1, { min: 1, max: 10, step: 1 }, { description: 'How many times to rock from side to side.' }),
angle: PD.Numeric(10, { min: 0, max: 180, step: 1 }, { description: 'How many degrees to rotate in each direction.' }),
axis: PD.Vec3(Vec3.create(0, -1, 0), {}, { description: 'Axis of rotation in camera space' }),
}),
initialState: (p, ctx) => ({ snapshot: ctx.canvas3d!.camera.getSnapshot() }) as State,
getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
@@ -47,11 +48,25 @@ export const AnimateCameraRock = PluginStateAnimation.create({
const angle = Math.sin(phase * ctx.params.speed * Math.PI * 2) * degToRad(ctx.params.angle);
Vec3.sub(_dir, snapshot.position, snapshot.target);
Vec3.normalize(_axis, snapshot.up);
// Transform axis from camera space to world space
Vec3.normalize(_axis, _dir); // Z = view direction
Vec3.normalize(_up, snapshot.up); // Y = up
Vec3.cross(_side, _up, _axis); // X = right
Vec3.normalize(_side, _side);
const a = ctx.params.axis ?? Vec3.create(0, -1, 0); // default for backwards compatibility
Vec3.set(_axis,
a[0] * _side[0] + a[1] * _up[0] + a[2] * _axis[0],
a[0] * _side[1] + a[1] * _up[1] + a[2] * _axis[1],
a[0] * _side[2] + a[1] * _up[2] + a[2] * _axis[2]
);
Vec3.normalize(_axis, _axis);
Quat.setAxisAngle(_rot, _axis, angle);
Vec3.transformQuat(_dir, _dir, _rot);
Vec3.transformQuat(_up, snapshot.up, _rot);
const position = Vec3.add(Vec3(), snapshot.target, _dir);
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position }, durationMs: 0 });
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position, up: _up }, durationMs: 0 });
if (phase >= 0.99999) {
return { kind: 'finished' };

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2020-2026 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>
*/
import { Camera } from '../../../mol-canvas3d/camera';
@@ -11,7 +12,7 @@ import { Vec3 } from '../../../mol-math/linear-algebra/3d/vec3';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { PluginStateAnimation } from '../model';
const _dir = Vec3(), _axis = Vec3(), _rot = Quat();
const _dir = Vec3(), _axis = Vec3(), _rot = Quat(), _up = Vec3(), _side = Vec3();
type State = { snapshot: Camera.Snapshot };
@@ -22,7 +23,7 @@ export const AnimateCameraSpin = PluginStateAnimation.create({
params: () => ({
durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
speed: PD.Numeric(1, { min: 1, max: 10, step: 1 }, { description: 'How many times to spin in the specified duration.' }),
direction: PD.Select<'cw' | 'ccw'>('cw', [['cw', 'Clockwise'], ['ccw', 'Counter Clockwise']], { cycle: true })
axis: PD.Vec3(Vec3.create(0, -1, 0), {}, { description: 'Axis of rotation in camera space' }),
}),
initialState: (_, ctx) => ({ snapshot: ctx.canvas3d?.camera.getSnapshot()! }) as State,
getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
@@ -42,14 +43,28 @@ export const AnimateCameraSpin = PluginStateAnimation.create({
const phase = t.animation
? t.animation?.currentFrame / (t.animation.frameCount + 1)
: clamp(t.current / ctx.params.durationInMs, 0, 1);
const angle = 2 * Math.PI * phase * ctx.params.speed * (ctx.params.direction === 'ccw' ? -1 : 1);
const angle = 2 * Math.PI * phase * ctx.params.speed;
Vec3.sub(_dir, snapshot.position, snapshot.target);
Vec3.normalize(_axis, snapshot.up);
// Transform axis from camera space to world space
Vec3.normalize(_axis, _dir); // Z = view direction
Vec3.normalize(_up, snapshot.up); // Y = up
Vec3.cross(_side, _up, _axis); // X = right
Vec3.normalize(_side, _side);
const a = ctx.params.axis ?? Vec3.create(0, -1, 0); // default for backwards compatibility
Vec3.set(_axis,
a[0] * _side[0] + a[1] * _up[0] + a[2] * _axis[0],
a[0] * _side[1] + a[1] * _up[1] + a[2] * _axis[1],
a[0] * _side[2] + a[1] * _up[2] + a[2] * _axis[2]
);
Vec3.normalize(_axis, _axis);
Quat.setAxisAngle(_rot, _axis, angle);
Vec3.transformQuat(_dir, _dir, _rot);
Vec3.transformQuat(_up, snapshot.up, _rot);
const position = Vec3.add(Vec3(), snapshot.target, _dir);
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position }, durationMs: 0 });
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position, up: _up }, durationMs: 0 });
if (phase >= 0.99999) {
return { kind: 'finished' };

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 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>
@@ -26,6 +26,7 @@ import { StructConn } from '../../../mol-model-formats/structure/property/bonds/
import { StructureRepresentationRegistry } from '../../../mol-repr/structure/registry';
import { assertUnreachable } from '../../../mol-util/type-helpers';
import { Vec3 } from '../../../mol-math/linear-algebra/3d/vec3';
import { Spheres } from '../../../mol-geo/geometry/spheres/spheres';
export interface StructureRepresentationPresetProvider<P = any, S extends _Result = _Result> extends PresetProvider<PluginStateObject.Molecule.Structure, P, S> { }
export function StructureRepresentationPresetProvider<P, S extends _Result>(repr: StructureRepresentationPresetProvider<P, S>) { return repr; }
@@ -495,6 +496,61 @@ const autoLod = StructureRepresentationPresetProvider({
}
});
type MesoscaleGraphicsMode = keyof typeof Spheres.LodLevelsPresets
const MesoscaleGraphicsOptions = PD.arrayToOptions(Object.keys(Spheres.LodLevelsPresets) as MesoscaleGraphicsMode[]);
function getMesoscaleLodLevels(mode: MesoscaleGraphicsMode) {
return Spheres.LodLevelsPresets[mode];
}
const mesoscale = StructureRepresentationPresetProvider({
id: 'preset-structure-representation-mesoscale',
display: {
name: 'Mesoscale', group: 'Miscellaneous',
description: 'Show everything in spacefill representation with instance-granularity and level-of-detail tuned for large particle scenes.'
},
params: () => ({
...CommonParams,
graphics: PD.Select<MesoscaleGraphicsMode>('quality', MesoscaleGraphicsOptions),
}),
async apply(ref, params, plugin) {
const structureCell = StateObjectRef.resolveAndCheck(plugin.state.data, ref);
if (!structureCell) return {};
const components = {
all: await presetStaticComponent(plugin, structureCell, 'all'),
};
const structure = structureCell.obj!.data;
const { update, builder, typeParams, color } = reprBuilder(plugin, params, structure);
const graphics: MesoscaleGraphicsMode = params.graphics ?? 'quality';
const lodLevels = getMesoscaleLodLevels(graphics);
const approximate = graphics !== 'quality' && graphics !== 'ultra';
const alphaThickness = graphics === 'performance' ? 15 : 12;
const representations = {
all: builder.buildRepresentation(update, components.all, {
type: 'spacefill',
typeParams: {
...typeParams,
instanceGranularity: true,
lodLevels,
approximate,
alphaThickness,
clipPrimitive: true,
},
color: color || 'entity-id',
}, { tag: 'all' }),
};
await update.commit({ revertOnError: true });
await updateFocusRepr(plugin, structure, params.theme?.focus?.name ?? color, params.theme?.focus?.params);
return { components, representations };
}
});
export function presetStaticComponent(plugin: PluginContext, structure: StateObjectRef<PluginStateObject.Molecule.Structure>, type: StaticStructureComponentType, params?: { label?: string, tags?: string[] }) {
return plugin.builders.structure.tryCreateComponentStatic(structure, type, params);
}
@@ -514,5 +570,6 @@ export const PresetStructureRepresentations = {
illustrative,
'molecular-surface': molecularSurface,
'auto-lod': autoLod,
mesoscale,
};
export type PresetStructureRepresentations = typeof PresetStructureRepresentations;

View File

@@ -1304,7 +1304,7 @@ const ShapeFromPly = PluginStateTransform.BuiltIn({
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value<Mat4[]>([], { isHidden: true })),
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}

View File

@@ -1414,8 +1414,8 @@ class ObjectListItem extends React.PureComponent<ObjectListItemProps, { isExpand
}
}
export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectList>, { isExpanded: boolean }> {
state = { isExpanded: false };
export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectList>, { isExpanded: boolean, showPresets: boolean }> {
state = { isExpanded: false, showPresets: false };
change(value: any) {
this.props.onChange({ name: this.props.name, param: this.props.param, value });
@@ -1459,12 +1459,29 @@ export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectL
e.currentTarget.blur();
};
toggleShowPresets = () => this.setState({ showPresets: !this.state.showPresets });
presetItems = memoizeLatest((param: PD.ObjectList) => ActionMenu.createItemsFromSelectOptions(param.presets ?? []));
onSelectPreset: ActionMenu.OnSelect = item => {
this.setState({ showPresets: false });
this.change(item?.value);
};
render() {
const v = this.props.value;
const label = this.props.param.label || camelCaseToWords(this.props.name);
const value = `${v.length} item${v.length !== 1 ? 's' : ''}`;
const hasPresets = !!this.props.param.presets;
const control = hasPresets
? <div className='msp-flex-row'>
<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>
<IconButton svg={BookmarksOutlinedSvg} onClick={this.toggleShowPresets} toggleState={this.state.showPresets} title='Presets' disabled={this.props.isDisabled} />
</div>
: <button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>;
return <>
<ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>} />
<ControlRow label={label} control={control} />
{hasPresets && this.state.showPresets && <ActionMenu items={this.presetItems(this.props.param)} onSelect={this.onSelectPreset} />}
{this.state.isExpanded && <div className='msp-control-offset'>
{this.props.value.map((v, i) => <ObjectListItem key={i} param={this.props.param} value={v} index={i} actions={this.actions} isDisabled={this.props.isDisabled} />)}
<ControlGroup header='New Item'>

View File

@@ -5,6 +5,7 @@
*/
import { Geometry, GeometryUtils } from '../../mol-geo/geometry/geometry';
import { resolveInstanceGranularity } from '../../mol-geo/geometry/base';
import { Representation } from '../representation';
import { Shape, ShapeGroup } from '../../mol-model/shape';
import { Subject } from 'rxjs';
@@ -129,7 +130,7 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
// console.log('update transform')
locationIt = Shape.groupIterator(_shape);
const { instanceCount, groupCount } = locationIt;
if (props.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', _renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', _renderObject.values);
@@ -197,14 +198,15 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, _shape.groupCount, _shape.transforms.length);
if (isEveryLoci(loci) || (Shape.isLoci(loci) && loci.shape === _shape)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, _shape.transforms.length));
} else {
return apply(Interval.ofBounds(0, _shape.groupCount * _shape.transforms.length));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, _shape, apply);
} else {
return eachShapeGroup(loci, _shape, apply);
@@ -226,7 +228,8 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
getLoci(pickingId: PickingId) {
const { objectId, groupId, instanceId } = pickingId;
if (_renderObject && _renderObject.id === objectId) {
if (groupId === PickingId.Null) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, _shape.groupCount, _shape.transforms.length);
if (groupId === PickingId.Null || instanceGranularity) {
return Shape.Loci(_shape);
} else {
return ShapeGroup.Loci(_shape, [{ ids: OrderedSet.ofSingleton(groupId), instance: instanceId }]);

View File

@@ -32,6 +32,7 @@ import { Text } from '../../mol-geo/geometry/text/text';
import { SizeTheme } from '../../mol-theme/size';
import { DirectVolume } from '../../mol-geo/geometry/direct-volume/direct-volume';
import { createMarkers } from '../../mol-geo/geometry/marker-data';
import { resolveInstanceGranularity } from '../../mol-geo/geometry/base';
import { StructureParams, StructureMeshParams, StructureTextParams, StructureDirectVolumeParams, StructureLinesParams, StructureCylindersParams, StructureTextureMeshParams, StructureSpheresParams, StructurePointsParams, StructureImageParams } from './params';
import { Clipping } from '../../mol-theme/clipping';
import { TextureMesh } from '../../mol-geo/geometry/texture-mesh/texture-mesh';
@@ -173,7 +174,7 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
if (updateState.updateTransform) {
// console.log('update transform')
const { instanceCount, groupCount } = locationIt;
if (newProps.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', renderObject.values);
@@ -237,14 +238,15 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean, isMarking: boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount);
if (lociIsSuperset(loci)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, locationIt.instanceCount));
} else {
return apply(Interval.ofBounds(0, locationIt.groupCount * locationIt.instanceCount));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, currentStructure, apply);
} else {
return eachLocation(loci, currentStructure, apply, isMarking);
@@ -279,7 +281,11 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
finalize(ctx);
},
getLoci(pickingId: PickingId) {
return renderObject ? getLoci(pickingId, currentStructure, renderObject.id) : EmptyLoci;
if (!renderObject) return EmptyLoci;
if (resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount)) {
pickingId = { ...pickingId, groupId: PickingId.Null };
}
return getLoci(pickingId, currentStructure, renderObject.id);
},
eachLocation(cb: LocationCallback) {
locationIt.reset();

View File

@@ -20,6 +20,7 @@ import { Interval } from '../../mol-data/int';
import { LocationCallback, VisualUpdateState } from '../util';
import { ColorTheme } from '../../mol-theme/color';
import { createMarkers } from '../../mol-geo/geometry/marker-data';
import { resolveInstanceGranularity } from '../../mol-geo/geometry/base';
import { MarkerAction } from '../../mol-util/marker-action';
import { ValueCell, deepEqual } from '../../mol-util';
import { createSizes } from '../../mol-geo/geometry/size-data';
@@ -214,7 +215,7 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
if (updateState.updateTransform) {
// console.log('update transform');
const { instanceCount, groupCount } = locationIt;
if (newProps.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', renderObject.values);
@@ -313,14 +314,15 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean, isMarking: boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount);
if (lociIsSuperset(loci)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, locationIt.instanceCount));
} else {
return apply(Interval.ofBounds(0, locationIt.groupCount * locationIt.instanceCount));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, currentStructureGroup, apply);
} else {
return eachLocation(loci, currentStructureGroup, apply, isMarking);
@@ -355,7 +357,11 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
finalize(ctx);
},
getLoci(pickingId: PickingId) {
return renderObject ? getLoci(pickingId, currentStructureGroup, renderObject.id) : EmptyLoci;
if (!renderObject) return EmptyLoci;
if (resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount)) {
pickingId = { ...pickingId, groupId: PickingId.Null };
}
return getLoci(pickingId, currentStructureGroup, renderObject.id);
},
eachLocation(cb: LocationCallback) {
locationIt.reset();

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Ludovic Autin <autin@scripps.edu>
*/
import { CustomProperties } from '../../../mol-model/custom-property';
import { Grid, Volume } from '../../../mol-model/volume';
import { Mat4, Tensor } from '../../../mol-math/linear-algebra';
import { createVolumeSphereImpostor } from '../dot';
function createTestVolume(dimensions: [number, number, number], data: number[]): Volume {
return {
grid: {
transform: { kind: 'matrix', matrix: Mat4.identity() },
cells: Tensor.create(Tensor.Space(dimensions, [2, 1, 0]), Tensor.Data1(data)),
stats: { min: 0, max: 1, mean: 0.5, sigma: 0.5 },
} satisfies Grid,
instances: [{ transform: Mat4.identity() }],
sourceData: { kind: 'test', name: 'test', data: {} } as any,
customProperties: new CustomProperties(),
_propertyData: Object.create(null),
_localPropertyData: Object.create(null),
};
}
describe('volume dot representation', () => {
it('adds sphere impostor dots in Morton order for LOD sampling', () => {
const volume = createTestVolume([2, 2, 2], [
1, 1,
1, 1,
1, 1,
1, 1,
]);
const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, {
isoValue: Volume.IsoValue.absolute(0.5),
perturbPositions: false,
lodLevels: [{ minDistance: 0, maxDistance: 0, overlap: 0, stride: 0, scaleBias: 3 }],
} as any);
expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 4, 2, 6, 1, 5, 3, 7]);
});
it('adds sphere impostor dots in row-major order when no LOD levels are configured', () => {
const volume = createTestVolume([2, 2, 2], [
1, 1,
1, 1,
1, 1,
1, 1,
]);
const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, {
isoValue: Volume.IsoValue.absolute(0.5),
perturbPositions: false,
lodLevels: [],
} as any);
expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
});
});

View File

@@ -67,7 +67,8 @@ export function VolumeSphereImpostorVisual(materialId: number): VolumeVisual<Vol
setUpdateState: (state: VisualUpdateState, newVolume: Volume, currentVolume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>, newTheme: Theme, currentTheme: Theme) => {
state.createGeometry = (
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, newVolume.grid.stats) ||
newProps.perturbPositions !== currentProps.perturbPositions
newProps.perturbPositions !== currentProps.perturbPositions ||
newProps.lodLevels.length > 0 && currentProps.lodLevels.length === 0
);
},
geometryUtils: Spheres.Utils,
@@ -128,38 +129,71 @@ export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, k
const p = Vec3();
const [xn, yn, zn] = space.dimensions;
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const invert = isoVal < 0;
// Precompute basis vectors and largest cell axis length
const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined;
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) continue;
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const cellIdx = space.dataOffset(x, y, z);
if (basis) {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const offset = getRandomOffsetFromBasis(basis);
Vec3.add(p, p, offset);
} else {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const add = (x: number, y: number, z: number) => {
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) return;
const cellIdx = space.dataOffset(x, y, z);
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
if (basis) {
Vec3.add(p, p, getRandomOffsetFromBasis(basis));
}
builder.add(p[0], p[1], p[2], cellIdx);
};
// Morton ordering keeps stride-based LOD sampling spatially balanced.
// Only worthwhile when LOD levels are configured; otherwise use the
// direct row-major path to avoid the extra allocations and sort.
const useMortonOrder = props.lodLevels.length > 0;
if (useMortonOrder) {
// Recursive octree traversal over the bounding power-of-two cube,
// visiting children in Morton order (octant bit2=x, bit1=y, bit0=z).
// Octants whose origin already exceeds the grid extent are pruned,
// so out-of-range subtrees of non-cube grids cost ~O(log) per skip.
let size = 1;
while (size < xn || size < yn || size < zn) size <<= 1;
const visit = (x0: number, y0: number, z0: number, s: number): void => {
if (x0 >= xn || y0 >= yn || z0 >= zn) return;
if (s === 1) {
add(x0, y0, z0);
return;
}
const h = s >> 1;
visit(x0, y0, z0, h);
visit(x0, y0, z0 + h, h);
visit(x0, y0 + h, z0, h);
visit(x0, y0 + h, z0 + h, h);
visit(x0 + h, y0, z0, h);
visit(x0 + h, y0, z0 + h, h);
visit(x0 + h, y0 + h, z0, h);
visit(x0 + h, y0 + h, z0 + h, h);
};
visit(0, 0, 0, size);
} else {
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
add(x, y, z);
}
builder.add(p[0], p[1], p[2], cellIdx);
}
}
}
const s = builder.getSpheres();
s.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
s.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return s;
}
@@ -209,7 +243,7 @@ export function createVolumeSphereMesh(ctx: VisualContext, volume: Volume, key:
}
const m = MeshBuilder.getMesh(builderState);
m.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
m.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return m;
}
@@ -277,7 +311,7 @@ export function createVolumePoint(ctx: VisualContext, volume: Volume, key: numbe
}
const pt = builder.getPoints();
pt.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
pt.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return pt;
}
@@ -320,6 +354,7 @@ const DotVisuals = {
export const DotParams = {
...VolumeSphereParams,
...VolumePointParams,
sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }),
visuals: PD.MultiSelect(['sphere'], PD.objectToOptions(DotVisuals)),
bumpFrequency: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
};
@@ -346,4 +381,4 @@ export const DotRepresentationProvider = VolumeRepresentationProvider({
defaultSizeTheme: { name: 'uniform' },
locationKinds: ['cell-location', 'position-location'],
isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume)
});
});

View File

@@ -136,7 +136,7 @@ export async function createVolumeIsosurfaceMesh(ctx: VisualContext, volume: Vol
ValueCell.updateIfChanged(surface.varyingGroup, true);
}
surface.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
surface.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return surface;
}
@@ -318,7 +318,7 @@ export async function createVolumeIsosurfaceWireframe(ctx: VisualContext, volume
const transform = Grid.getGridToCartesianTransform(volume.grid);
Lines.transform(wireframe, transform);
wireframe.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
wireframe.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return wireframe;
}

View File

@@ -306,7 +306,7 @@ function getSampledImage(volume: Volume, theme: Theme, info: SamplingInfo, isoVa
const isoLevel = clamp(normalize(Volume.IsoValue.toAbsolute(isoValue, stats).absoluteValue, min, max), 0, 1);
const im = Image.create(imageTexture, corners, groupTexture, valueTexture, trim, isoLevel, image);
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume) : Grid.getBoundingSphere(volume.grid));
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume, Interval.ofLength(volume.instances.length as Volume.InstanceIndex)) : Grid.getBoundingSphere(volume.grid));
im.meta.mapping = mapping;
return im;
@@ -480,7 +480,7 @@ async function createGridImage(ctx: VisualContext, volume: Volume, key: number,
const isoLevel = clamp(normalize(Volume.IsoValue.toAbsolute(isoValue, stats).absoluteValue, min, max), 0, 1);
const im = Image.create(imageTexture, corners, groupTexture, valueTexture, trim, isoLevel, image);
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume) : Grid.getBoundingSphere(volume.grid));
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume, Interval.ofLength(volume.instances.length as Volume.InstanceIndex)) : Grid.getBoundingSphere(volume.grid));
im.meta.mapping = mapping;
return im;

View File

@@ -33,7 +33,7 @@ import { Emissive } from '../../mol-theme/emissive';
import { Wiggle } from '../../mol-theme/wiggle';
import { SizeTheme } from '../../mol-theme/size';
import { Sphere3D } from '../../mol-math/geometry/primitives/sphere3d';
import { BaseGeometry } from '../../mol-geo/geometry/base';
import { BaseGeometry, resolveInstanceGranularity } from '../../mol-geo/geometry/base';
export const VolumeParams = {
...BaseGeometry.Params,
@@ -182,7 +182,7 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
if (updateState.updateTransform || updateState.updateLocation) {
// console.log('update transform');
const { instanceCount, groupCount } = locationIt;
if (newProps.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', renderObject.values);
@@ -279,14 +279,15 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount);
if (isEveryLoci(loci)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, locationIt.instanceCount));
} else {
return apply(Interval.ofBounds(0, locationIt.groupCount * locationIt.instanceCount));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, currentVolume, currentKey, apply);
} else {
return eachLocation(loci, currentVolume, currentKey, currentProps, apply, geometry);
@@ -308,7 +309,11 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
}
},
getLoci(pickingId: PickingId) {
return renderObject ? getLoci(pickingId, currentVolume, currentKey, currentProps, renderObject.id, geometry) : EmptyLoci;
if (!renderObject) return EmptyLoci;
if (resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount)) {
pickingId = { ...pickingId, groupId: PickingId.Null };
}
return getLoci(pickingId, currentVolume, currentKey, currentProps, renderObject.id, geometry);
},
eachLocation(cb: LocationCallback) {
locationIt.reset();

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*/
import { State, StateObject, StateTransformer } from '../../mol-state';
import { Task } from '../../mol-task';
interface TypeInfo { name: string; typeClass: 'Root' | 'Data' }
const Create = StateObject.factory<TypeInfo>();
class Root extends Create({ name: 'Root', typeClass: 'Root' }) { }
class Leaf extends Create<{ value: number }>({ name: 'Leaf', typeClass: 'Data' }) { }
const NS = 'state-dispose-spec';
let counter = 0;
function leafTransformer(spy: () => void) {
return StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: `create-leaf-${counter++}`,
from: [Root],
to: [Leaf],
display: { name: 'Create Leaf' },
params: () => ({} as any),
apply({ params }) { return new Leaf({ value: params.value }); },
dispose() { spy(); }
});
}
function chainedTransformer(spy: () => void) {
return StateTransformer.create<Leaf, Leaf, {}>(NS, {
name: `chained-leaf-${counter++}`,
from: [Leaf],
to: [Leaf],
display: { name: 'Chained Leaf' },
apply({ a }) { return new Leaf({ value: a.data.value + 1 }); },
dispose() { spy(); }
});
}
function newState() {
return State.create(new Root({}), { runTask: <T>(t: Task<T>) => t.run() });
}
describe('State.dispose', () => {
it('calls transformer.dispose for every live cell', async () => {
const leafSpy = jest.fn();
const chainSpy = jest.fn();
const A = leafTransformer(leafSpy);
const B = chainedTransformer(chainSpy);
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 1 }).apply(B as any, {});
await state.runTask(state.updateTree(builder));
// root + 2 transformer outputs.
expect(state.cells.size).toBe(3);
state.dispose();
expect(leafSpy).toHaveBeenCalledTimes(1);
expect(chainSpy).toHaveBeenCalledTimes(1);
});
it('disposes all sibling subtrees', async () => {
const spyA = jest.fn();
const spyB = jest.fn();
const A = leafTransformer(spyA);
const B = leafTransformer(spyB);
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 1 });
builder.toRoot<Root>().apply(B as any, { value: 2 });
await state.runTask(state.updateTree(builder));
state.dispose();
expect(spyA).toHaveBeenCalledTimes(1);
expect(spyB).toHaveBeenCalledTimes(1);
});
it('does not throw when a transformer dispose throws', async () => {
const goodSpy = jest.fn();
const Throwing = StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: `throwing-leaf-${counter++}`,
from: [Root],
to: [Leaf],
display: { name: 'Throwing Leaf' },
apply({ params }) { return new Leaf({ value: params.value }); },
dispose() { throw new Error('boom'); }
});
const Good = leafTransformer(goodSpy);
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(Throwing as any, { value: 1 });
builder.toRoot<Root>().apply(Good as any, { value: 2 });
await state.runTask(state.updateTree(builder));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
expect(() => state.dispose()).not.toThrow();
} finally {
warn.mockRestore();
}
expect(goodSpy).toHaveBeenCalledTimes(1);
});
it('is a no-op for transformers without a dispose definition', async () => {
const NoDispose = StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: `no-dispose-${counter++}`,
from: [Root],
to: [Leaf],
display: { name: 'No-dispose Leaf' },
apply({ params }) { return new Leaf({ value: params.value }); }
});
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(NoDispose as any, { value: 1 });
await state.runTask(state.updateTree(builder));
expect(() => state.dispose()).not.toThrow();
});
});

View File

@@ -159,6 +159,23 @@ class State {
}
dispose() {
// Dispose every still-live cell so transformer dispose callbacks
// (e.g. WebGL/GL buffer cleanup) actually run. Without this,
// calling dispose() on a State that still has cells leaks any
// resources held by transformer dispose callbacks because they
// would only fire on per-cell deletion (see updateNode/findDeletes).
const refs: StateTransform.Ref[] = [];
StateTree.doPostOrder(this._tree, this._tree.root, { refs }, (n, _, s) => { s.refs.push(n.ref); });
for (let i = refs.length - 1; i >= 0; i--) {
const cell = (this.cells as Map<StateTransform.Ref, StateObjectCell>).get(refs[i]);
if (!cell) continue;
try {
dispose(cell.transform, cell.obj, cell.transform.params, cell.cache, this.globalContext);
} catch (e) {
console.warn('Error in transformer dispose during State.dispose', e);
}
}
this.ev.dispose();
this.actions.dispose();
}

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*
* Adapted from kin-parser.ts file from the NGL project:
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* Adapted from hsl.ts in this same directory:
* @author David Sehnal <david.sehnal@gmail.com>
*/
import type { Color } from '../color';
import { Rgb } from './rgb';
export { Hsv };
/** Hsv tuple: [h, s, v]
* - h in [0,360] degrees
* - s in [0,100] percent
* - v in [0,100] percent
*/
interface Hsv extends Array<number> { [d: number]: number, '@type': 'hsv', length: 3 }
function Hsv() {
return Hsv.zero();
}
namespace Hsv {
export function zero(): Hsv {
const out = [0.0, 0.0, 0.0];
out[0] = 0;
return out as Hsv;
}
/** Copy values from an array-like 3-tuple into `out`. */
export function fromArray(arr: ArrayLike<number>): Hsv {
const out = Hsv.zero();
out[0] = arr[0] ?? 0;
out[1] = arr[1] ?? 0;
out[2] = arr[2] ?? 0;
return out;
}
const _rgb = Rgb();
export function toColor(hsv: Hsv): Color {
toRgb(_rgb, hsv);
return Rgb.toColor(_rgb);
}
export function toRgb(out: Rgb, hsv: Hsv) {
let [h, s, v] = hsv;
h /= 360;
s /= 100;
v /= 100;
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
out[0] = r;
out[1] = g;
out[2] = b;
return out;
}
}

View File

@@ -2,6 +2,7 @@
* Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
*/
export { PixelData };
@@ -37,12 +38,14 @@ namespace PixelData {
/** to undo pre-multiplied alpha */
export function divideByAlpha(pixelData: PixelData): PixelData {
const { array } = pixelData;
const factor = (array instanceof Uint8Array) ? 255 : 1;
// clamp: emissive, bloom and antialiasing can lift premul RGB above alpha; without it Uint8Array silently wraps.
const max = (array instanceof Uint8Array) ? 255 : 1;
for (let i = 0, il = array.length; i < il; i += 4) {
const a = array[i + 3] / factor;
array[i] /= a;
array[i + 1] /= a;
array[i + 2] /= a;
const a = array[i + 3] / max;
if (a === 0) continue;
array[i] = Math.min(max, array[i] / a);
array[i + 1] = Math.min(max, array[i + 1] / a);
array[i + 2] = Math.min(max, array[i + 2] / a);
}
return pixelData;
}

View File

@@ -295,10 +295,13 @@ export namespace ParamDefinition {
type: 'object-list',
element: Params,
ctor(): T,
getLabel(t: T): string
getLabel(t: T): string,
presets?: Select<T[]>['options']
}
export function ObjectList<T>(element: For<T>, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T }): ObjectList<Normalize<T>> {
return setInfo<ObjectList<Normalize<T>>>({ type: 'object-list', element: element as any as Params, getLabel, ctor: _defaultObjectListCtor, defaultValue: (info?.defaultValue) || [] }, info);
export function ObjectList<T>(element: For<T>, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T, presets?: Select<T[]>['options'] }): ObjectList<Normalize<T>> {
const ret = setInfo<ObjectList<Normalize<T>>>({ type: 'object-list', element: element as any as Params, getLabel, ctor: _defaultObjectListCtor, defaultValue: (info?.defaultValue) || [] }, info);
if (info?.presets) ret.presets = info.presets as any;
return ret;
}
function _defaultObjectListCtor(this: ObjectList) { return getDefaultValues(this.element) as any; }

View File

@@ -1,3 +1,7 @@
# 0.9.13
* /surroundingLigands: honor `omit_water=true|false` for REST GET requests (boolean parser previously coerced both to `false`)
* /surroundingLigands: stop leaking the asymmetric unit's water chain into the result when `omit_water=true` (water residues pulled in via struct_conn covale/metalc edges no longer match every other water in the chain)
# 0.9.12
* add `health-check` endpoint + `healthCheckPath` config prop to report service health

View File

@@ -295,7 +295,7 @@ function _normalizeQueryParams(params: { [p: string]: string }, paramList: Query
case QueryParamType.String: el = value; break;
case QueryParamType.Integer: el = parseInt(value); break;
case QueryParamType.Float: el = parseFloat(value); break;
case QueryParamType.Boolean: el = Boolean(+value); break;
case QueryParamType.Boolean: el = isTrue(value); break;
}
if (p.validation) p.validation(el);

View File

@@ -4,4 +4,4 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
export const VERSION = '0.9.12';
export const VERSION = '0.9.13';