Compare commits

...

100 Commits

Author SHA1 Message Date
Alexander Rose
6550771d65 add obj format support 2026-05-31 22:14:45 -07: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
Alexander Rose
86a74d1cc2 5.9.0 2026-05-03 10:45:51 -07:00
Alexander Rose
3f0f24cb99 changelog 2026-05-03 10:44:40 -07:00
Alexander Rose
b8ddc142ea schema updates 2026-05-03 00:15:31 -07:00
Alexander Rose
cccaa48589 ts6 tweaks 2026-05-03 00:12:36 -07:00
Alexander Rose
3ad355ad40 package updates 2026-05-03 00:12:23 -07:00
Alexander Rose
918186eb24 Merge pull request #1805 from molstar/proc-anim
Procedural animation
2026-05-02 16:18:22 -07:00
Alexander Rose
db4742cebf tweaks 2026-05-02 16:16:39 -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
Alexander Rose
7d6c77b3bd Merge branch 'master' of https://github.com/molstar/molstar into proc-anim 2026-05-02 09:34:00 -07:00
Alexander Rose
dfcc4e400d add animation support to texture-mesh geometry 2026-04-27 22:30:58 -07:00
Alexander Rose
c9734d83a2 Merge pull request #1818 from corredD/codex/fix-assembly-symmetry
Fix GraphQL POST request handling for Assembly Symmetry
2026-04-25 07:57:40 -07:00
Alexander Rose
93943cc27b changelog 2026-04-25 07:55:08 -07:00
Alexander Rose
25836b2de0 changelog 2026-04-25 07:50:28 -07:00
Alexander Rose
c6874c922d use record for headers
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 07:50:21 -07:00
Ludovic Autin
0937c84f47 Fix GraphQL POST request handling 2026-04-18 22:20:57 -07:00
Alexander Rose
6a7f892d60 cleanup & changelog 2026-04-18 10:57:14 -07:00
Alexander Rose
b4cd2d0a11 Merge branch 'master' of https://github.com/molstar/molstar into proc-anim 2026-04-18 10:56:12 -07:00
Alexander Rose
2067f02830 changelog 2026-04-18 10:55:09 -07:00
Alexander Rose
6d86ada6b4 getNucleicOneLetterCode 2026-04-18 10:54:48 -07:00
Alexander Rose
f656cf09b7 Merge pull request #1813 from corredD/codex/slice-marking-optimization
Slice marking optimization
2026-04-18 10:54:14 -07:00
Alexander Rose
a891b4c551 tweaks and changelog 2026-04-18 10:50:23 -07:00
Alexander Rose
ded844c936 Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/1813 2026-04-18 10:46:58 -07:00
Alexander Rose
44b36637fd Merge pull request #1802 from molstar/ccd-bonds-deuterium
Handle CCD bonds with Deuterium atoms
2026-04-18 10:45:26 -07:00
Alexander Rose
f590bd0f0a Merge branch 'master' into ccd-bonds-deuterium 2026-04-18 10:29:12 -07:00
Alexander Rose
9474c80673 Merge pull request #1812 from molstar/8k-uhd-image-option
Add 8K UHD option to `ViewportScreenshotHelper`
2026-04-18 10:28:05 -07:00
Alexander Rose
7b48d691c8 Merge branch 'master' into 8k-uhd-image-option 2026-04-18 10:27:54 -07:00
Alexander Rose
b03146852f Merge pull request #1811 from molstar/mrc-empty-length 2026-04-18 09:34:42 -07:00
Ludovic Autin
9345f3584a Update slice marking file headers 2026-04-12 22:46:06 -07:00
Ludovic Autin
4d058aa1a8 Merge commit '94f6b864b0ede5c88b98725648178ceda5b7340b' into codex/slice-marking-optimization 2026-04-12 22:03:35 -07:00
Ludovic Autin
e7da6092aa Optimize slice marking for hover 2026-04-12 21:53:08 -07:00
David Sehnal
94f6b864b0 Fix empty PluginSpec.animations edgecase (#1810) 2026-04-12 19:37:21 +02:00
Alexander Rose
6e90447511 Add 8K UHD option to ViewportScreenshotHelper 2026-04-11 09:11:16 -07:00
Alexander Rose
b91030c4bd Handle MRC files with empty length header fields 2026-04-11 09:08:57 -07:00
Alexander Rose
9716fecdb9 add time only animation for exporting 2026-04-02 11:39:11 -07:00
Alexander Rose
684fd2d237 Merge branch 'master' of https://github.com/molstar/molstar into proc-anim 2026-04-01 16:34:01 -07:00
Alexander Rose
3a37c95c17 scale tumble with bounding-sphere 2026-04-01 16:32:36 -07:00
Alexander Rose
6040b99c19 Merge branch 'master' of https://github.com/molstar/molstar into proc-anim 2026-04-01 14:22:50 -07:00
Alexander Rose
83bef0f0e7 proc anim panel & per-group wiggle 2026-04-01 14:22:38 -07:00
Alexander Rose
be677f47cb basic procedural animation 2026-03-27 12:55:00 -07:00
Alexander Rose
43bf69d09c handle ComponentBond.Entry.map changes 2026-03-27 09:35:25 -07:00
Alexander Rose
b6cc626431 cleanup 2026-03-27 09:14:46 -07:00
Alexander Rose
931fdfca9b move ccd logic to ComponentBond 2026-03-27 09:11:55 -07:00
Alexander Rose
6c99c575bc Handle CCD bonds with Deuterium atoms 2026-03-26 16:23:32 -07:00
133 changed files with 15381 additions and 11526 deletions

View File

@@ -4,6 +4,42 @@ 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
- 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
- Add OBJ format support
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty
- Add 8K UHD option to `ViewportScreenshotHelper`
- Handle MRC files with empty length header fields
- Handle CCD bonds with Deuterium atoms
- [Breaking] ComponentBond.Entry.map now returns ComponentBond.Pairs
- Fix volume slice marking performance regression
- Add GPU procedural animation (wiggle & tumble)
- Per-vertex wiggle via fbm noise (position & group mode)
- Per-instance tumble via fbm noise (rotation + translation)
- `Wiggle` theme layer for data-driven per-group wiggle
- `enableAnimation` Canvas3D param for global toggle
- Add `AnimateTime` built-in for, e.g., exporting procedural animation
- Add Procedural Animation panels
- Viewer: structure dynamics & uncertainty
- Mesoscale Explorer: entity dynamics
- Fix `GraphQLClient` missing required headers
- [Breaking] Use Record instead of Array for headers (assets & data-source utils)
## [v5.8.0] - 2026-04-03
- Dependencies: remove `utils.promisify`, `node-fetch` (#1797)

View File

@@ -48,4 +48,7 @@
* CLR (e.g. 3GKI) - four fused rings
* Assembly symmetries
* 5M30 (Assembly 1, C3 local and pseudo)
* 1RB8 (Assembly 1, I global)
* 1RB8 (Assembly 1, I global)
* Deuterium atoms
* 3CWH (XUL with D and DOD)
* 8TT8 (HOH and other with D)

21944
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "molstar",
"version": "5.8.0",
"version": "5.9.0",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {
@@ -74,7 +74,7 @@
"js"
],
"transform": {
"\\.ts$": "esbuild-jest-transform"
"\\.ts$": ["esbuild-jest-transform", { "tsconfigRaw": "{\"compilerOptions\":{\"useDefineForClassFields\":false}}" }]
},
"moduleDirectories": [
"node_modules",
@@ -136,47 +136,47 @@
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/webxr": "^0.5.24",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"benchmark": "^2.1.4",
"concurrently": "^9.2.1",
"cpx2": "^8.0.0",
"cpx2": "^8.0.2",
"css-loader": "^7.1.4",
"esbuild": "^0.27.3",
"esbuild": "^0.28.0",
"esbuild-jest-transform": "^2.0.1",
"esbuild-sass-plugin": "^3.6.0",
"eslint": "^10.0.2",
"fs-extra": "^11.3.3",
"globals": "^17.3.0",
"esbuild-sass-plugin": "^3.7.0",
"eslint": "^10.3.0",
"fs-extra": "^11.3.4",
"globals": "^17.6.0",
"http-server": "^14.1.1",
"jest": "^30.2.0",
"jest": "^30.3.0",
"jpeg-js": "^0.4.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass": "^1.97.3",
"simple-git": "^3.32.3",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
"sass": "^1.99.0",
"simple-git": "^3.36.0",
"tsc-alias": "^1.8.17",
"typescript": "^6.0.3"
},
"dependencies": {
"@types/argparse": "^2.0.17",
"@types/benchmark": "^2.1.5",
"@types/compression": "1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.19.13",
"@types/node": "^22.19.17",
"@types/swagger-ui-dist": "3.30.6",
"argparse": "^2.0.1",
"compression": "^1.8.1",
"cors": "^2.8.6",
"express": "^5.2.1",
"h264-mp4-encoder": "^1.0.12",
"immutable": "^5.1.4",
"immutable": "^5.1.5",
"io-ts": "^2.2.22",
"mutative": "^1.3.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.2",
"swagger-ui-dist": "^5.32.0",
"swagger-ui-dist": "^5.32.5",
"tslib": "^2.8.1"
},
"peerDependencies": {

View File

@@ -15,7 +15,7 @@ import { GraphicsMode, MesoscaleGroup, MesoscaleState, getGraphicsModeProps, get
import { ColorNames } from '../../../../mol-util/color/names';
import { ShapeRepresentation3D, StructureRepresentation3D } from '../../../../mol-plugin-state/transforms/representation';
import { ParseCif, ParsePly, ReadFile } from '../../../../mol-plugin-state/transforms/data';
import { ModelFromTrajectory, ShapeFromPly, TrajectoryFromGRO, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromMmCif, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../../../mol-plugin-state/transforms/model';
import { ModelFromTrajectory, TrajectoryFromGRO, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromMmCif, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../../../mol-plugin-state/transforms/model';
import { Euler } from '../../../../mol-math/linear-algebra/3d/euler';
import { Asset } from '../../../../mol-util/assets';
import { Clip } from '../../../../mol-util/clip';
@@ -24,6 +24,7 @@ import { getFileNameInfo } from '../../../../mol-util/file-info';
import { NumberArray } from '../../../../mol-util/type-helpers';
import { BaseGeometry } from '../../../../mol-geo/geometry/base';
import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
import { ShapeFromPly } from '../../../../mol-plugin-state/transforms/shape';
function getSpacefillParams(color: Color, sizeFactor: number, graphics: GraphicsMode, clipVariant: Clip.Variant) {
const gmp = getGraphicsModeProps(graphics === 'custom' ? 'quality' : graphics);

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -10,6 +10,7 @@ import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { Task } from '../../../mol-task';
import { Color } from '../../../mol-util/color';
import { Spheres } from '../../../mol-geo/geometry/spheres/spheres';
import { getAnimationParam } from '../../../mol-geo/geometry/animation';
import { Clip } from '../../../mol-util/clip';
import { escapeRegExp, stringToWords } from '../../../mol-util/string';
import { Mat4, Vec3 } from '../../../mol-math/linear-algebra';
@@ -21,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';
@@ -174,6 +174,8 @@ export const LodParams = {
approximate: Spheres.Params.approximate,
};
export const AnimationParams = getAnimationParam().params;
export const SimpleClipParams = {
type: PD.Select('none', PD.objectToOptions(Clip.Type, t => stringToWords(t))),
invert: PD.Boolean(false),
@@ -281,6 +283,7 @@ export const MesoscaleGroupParams = {
emissive: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }),
lod: PD.Group(LodParams),
clip: PD.Group(SimpleClipParams),
animation: PD.Group(AnimationParams),
};
export type MesoscaleGroupProps = PD.Values<typeof MesoscaleGroupParams>;
@@ -318,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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2022-2024 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>
*/
@@ -18,7 +18,7 @@ import { CombinedColorControl } from '../../../mol-plugin-ui/controls/color';
import { MarkerAction } from '../../../mol-util/marker-action';
import { EveryLoci, Loci } from '../../../mol-model/loci';
import { deepEqual } from '../../../mol-util';
import { ColorValueParam, ColorParams, ColorProps, DimLightness, LightnessParams, LodParams, MesoscaleGroup, MesoscaleGroupProps, OpacityParams, SimpleClipParams, SimpleClipProps, createClipMapping, getClipObjects, getDistinctGroupColors, RootParams, MesoscaleState, getRoots, getAllGroups, getAllLeafGroups, getFilteredEntities, getAllFilteredEntities, getGroups, getEntities, getAllEntities, getEntityLabel, updateColors, getGraphicsModeProps, GraphicsMode, MesoscaleStateParams, setGraphicsCanvas3DProps, PatternParams, expandAllGroups, EmissiveParams, IllustrativeParams, getCellDescription, getEntityDescription, getEveryEntity } from '../data/state';
import { ColorValueParam, ColorParams, ColorProps, DimLightness, LightnessParams, LodParams, AnimationParams, MesoscaleGroup, MesoscaleGroupProps, OpacityParams, SimpleClipParams, SimpleClipProps, createClipMapping, getClipObjects, getDistinctGroupColors, RootParams, MesoscaleState, getRoots, getAllGroups, getAllLeafGroups, getFilteredEntities, getAllFilteredEntities, getGroups, getEntities, getAllEntities, getEntityLabel, updateColors, getGraphicsModeProps, GraphicsMode, MesoscaleStateParams, setGraphicsCanvas3DProps, PatternParams, expandAllGroups, EmissiveParams, IllustrativeParams, getCellDescription, getEntityDescription, getEveryEntity } from '../data/state';
import React, { useState } from 'react';
import { MesoscaleExplorerState } from '../app';
import { StructureElement } from '../../../mol-model/structure/structure/element';
@@ -828,6 +828,26 @@ export class GroupNode extends Node<{ filter: string }, { isCollapsed: boolean,
update.commit();
};
updateAnimation = (values: PD.Values) => {
const update = this.plugin.state.data.build();
for (const r of this.allFilteredEntities) {
update.to(r).update(old => {
if (old.type) {
old.type.params.animation = values;
}
});
}
for (const g of this.allGroups) {
update.to(g).update(old => {
old.animation = values;
});
}
update.commit();
};
update = (props: MesoscaleGroupProps) => {
this.plugin.state.data.build().to(this.ref).update(props);
};
@@ -865,6 +885,7 @@ export class GroupNode extends Node<{ filter: string }, { isCollapsed: boolean,
const rootValue = this.cell.params?.values.color;
const clipValue = this.cell.params?.values.clip;
const lodValue = this.cell.params?.values.lod;
const animationValue = this.cell.params?.values.animation;
const isRoot = this.cell.params?.values.root;
const groups = this.groups;
@@ -904,6 +925,7 @@ export class GroupNode extends Node<{ filter: string }, { isCollapsed: boolean,
topRightIcon={CloseSvg} noTopMargin childrenClassName='msp-viewport-controls-panel-controls'>
<ParameterControls params={SimpleClipParams} values={clipValue} onChangeValues={this.updateClip} />
<ParameterControls params={LodParams} values={lodValue} onChangeValues={this.updateLod} />
<ParameterControls params={AnimationParams} values={animationValue} onChangeValues={this.updateAnimation} />
</ControlGroup>
</div>}
{this.state.action === 'root' && <div style={{ marginRight: 5 }} className='msp-accent-offset'>
@@ -1080,6 +1102,19 @@ export class EntityNode extends Node<{}, { action?: 'color' | 'clip', isDisabled
};
}
get animationValue(): PD.Values<typeof AnimationParams> | undefined {
const p = this.cell.transform.params?.type?.params?.animation;
if (!p) return;
return {
wiggleMode: p.wiggleMode,
wiggleSpeed: p.wiggleSpeed,
wiggleAmplitude: p.wiggleAmplitude,
wiggleFrequency: p.wiggleFrequency,
tumbleSpeed: p.tumbleSpeed,
tumbleAmplitude: p.tumbleAmplitude,
};
}
get patternValue(): { amplitude: number, frequency: number } | undefined {
const p = this.cell.transform.params;
if (p.type) return;
@@ -1194,6 +1229,15 @@ export class EntityNode extends Node<{}, { action?: 'color' | 'clip', isDisabled
}
};
updateAnimation = (values: PD.Values) => {
const params = this.cell.transform.params as StateTransformer.Params<StructureRepresentation3D>;
if (!params.type) return;
this.plugin.build().to(this.ref).update(old => {
old.type.params.animation = values;
}).commit();
};
updatePattern = (values: PD.Values) => {
return this.plugin.build().to(this.ref).update(old => {
if (!old.type) {
@@ -1213,6 +1257,7 @@ export class EntityNode extends Node<{}, { action?: 'color' | 'clip', isDisabled
const opacityValue = this.opacityValue;
const emissiveValue = this.emissiveValue;
const lodValue = this.lodValue;
const animationValue = this.animationValue;
const patternValue = this.patternValue;
const l = getEntityLabel(this.plugin, this.cell);
@@ -1251,6 +1296,7 @@ export class EntityNode extends Node<{}, { action?: 'color' | 'clip', isDisabled
topRightIcon={CloseSvg} noTopMargin childrenClassName='msp-viewport-controls-panel-controls'>
<ParameterMappingControl mapping={this.clipMapping} />
{lodValue && <ParameterControls params={LodParams} values={lodValue} onChangeValues={this.updateLod} />}
{animationValue && <ParameterControls params={AnimationParams} values={animationValue} onChangeValues={this.updateAnimation} />}
</ControlGroup>
</div>}
</>;

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2022-2025 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>
*/
@@ -13,7 +13,7 @@ import { StructureMeasurementsControls } from '../../../mol-plugin-ui/structure/
import { MesoscaleExplorerState } from '../app';
import { MesoscaleState } from '../data/state';
import { EntityControls, FocusInfo, ModelInfo, SelectionInfo } from './entities';
import { LoaderControls, ExampleControls, SessionControls, SnapshotControls, DatabaseControls, MesoQuickStylesControls, ExplorerInfo } from './states';
import { LoaderControls, ExampleControls, SessionControls, SnapshotControls, DatabaseControls, MesoQuickStylesControls, MesoProceduralAnimationControls, ExplorerInfo } from './states';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { TuneSvg } from '../../../mol-plugin-ui/controls/icons';
import { RendererParams } from '../../../mol-gl/renderer';
@@ -145,6 +145,7 @@ export class RightPanel extends PluginUIComponent<{}, { isDisabled: boolean }> {
<StructureMeasurementsControls initiallyCollapsed={true}/>
</>
<MesoQuickStylesControls />
<MesoProceduralAnimationControls />
<Spacer />
<SectionHeader title='Entities' />
<EntityControls />

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2022-2025 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>
*/
@@ -8,7 +8,7 @@ import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
import { MmcifProvider } from '../../../mol-plugin-state/formats/trajectory';
import { PluginStateObject } from '../../../mol-plugin-state/objects';
import { Button, ExpandGroup, IconButton } from '../../../mol-plugin-ui/controls/common';
import { GetAppSvg, HelpOutlineSvg, MagicWandSvg, TourSvg, Icon, OpenInBrowserSvg } from '../../../mol-plugin-ui/controls/icons';
import { AnimationSvg, GetAppSvg, HelpOutlineSvg, MagicWandSvg, TourSvg, Icon, OpenInBrowserSvg } from '../../../mol-plugin-ui/controls/icons';
import { CollapsableControls, PluginUIComponent } from '../../../mol-plugin-ui/base';
import { ApplyActionControl } from '../../../mol-plugin-ui/state/apply-action';
import { LocalStateSnapshotList, LocalStateSnapshotParams, LocalStateSnapshots } from '../../../mol-plugin-ui/state/snapshots';
@@ -24,7 +24,7 @@ import { createCellpackHierarchy } from '../data/cellpack/preset';
import { createGenericHierarchy } from '../data/generic/preset';
import { createMmcifHierarchy } from '../data/mmcif/preset';
import { createPetworldHierarchy } from '../data/petworld/preset';
import { getAllEntities, getEntityLabel, MesoscaleState, MesoscaleStateObject, setGraphicsCanvas3DProps, updateStyle } from '../data/state';
import { getAllEntities, getAllGroups, getEntityLabel, MesoscaleState, MesoscaleStateObject, setGraphicsCanvas3DProps, updateStyle } from '../data/state';
import { isTimingMode } from '../../../mol-util/debug';
import { now } from '../../../mol-util/now';
import { readFromFile } from '../../../mol-util/data-source';
@@ -779,3 +779,110 @@ export class MesoQuickStyles extends PluginUIComponent {
</>;
}
}
export class MesoProceduralAnimationControls extends CollapsableControls {
defaultState() {
return {
isCollapsed: true,
header: 'Procedural Animation',
brand: { accent: 'gray' as const, svg: AnimationSvg }
};
}
renderControls() {
return <>
<MesoProceduralAnimation />
</>;
}
}
class MesoProceduralAnimation extends PluginUIComponent {
private isMembrane(cell: { transform: { tags?: string[] } }) {
return cell.transform.tags?.some(t => t.includes('mem')) ?? false;
}
async dynamics() {
const update = this.plugin.state.data.build();
const entities = getAllEntities(this.plugin);
const groups = getAllGroups(this.plugin);
for (const entity of entities) {
const membrane = this.isMembrane(entity);
update.to(entity).update(old => {
if (old.type) {
old.type.params.animation = {
...old.type.params.animation,
wiggleMode: 'position',
wiggleSpeed: 7,
wiggleAmplitude: 1,
wiggleFrequency: 0.2,
tumbleSpeed: 1,
tumbleAmplitude: membrane ? 0 : 4,
tumbleFrequency: 0.2,
};
}
});
}
for (const group of groups) {
const membrane = this.isMembrane(group);
update.to(group).update(old => {
old.animation = {
...old.animation,
wiggleMode: 'position',
wiggleSpeed: 7,
wiggleAmplitude: 1,
wiggleFrequency: 0.2,
tumbleSpeed: 1,
tumbleAmplitude: membrane ? 0 : 4,
tumbleFrequency: 0.2,
};
});
}
await update.commit();
}
async clear() {
const update = this.plugin.state.data.build();
const entities = getAllEntities(this.plugin);
const groups = getAllGroups(this.plugin);
for (const entity of entities) {
update.to(entity).update(old => {
if (old.type) {
old.type.params.animation = {
...old.type.params.animation,
wiggleAmplitude: 0,
tumbleAmplitude: 0,
};
}
});
}
for (const group of groups) {
update.to(group).update(old => {
old.animation = {
...old.animation,
wiggleAmplitude: 0,
tumbleAmplitude: 0,
};
});
}
await update.commit();
}
render() {
return <>
<div className='msp-flex-row'>
<Button noOverflow title='Enable wiggle for all entities and tumble for non-membrane entities' onClick={() => this.dynamics()} style={{ width: 'auto' }}>
Dynamics
</Button>
<Button noOverflow title='Set wiggle and tumble amplitude to zero for all entities' onClick={() => this.clear()} style={{ width: 'auto' }}>
Clear
</Button>
</div>
</>;
}
}

View File

@@ -59,7 +59,7 @@ export async function getG3dDataBlock(ctx: PluginContext, header: G3dHeader, url
async function getRawData(ctx: PluginContext, urlOrData: string | Uint8Array, range: { offset: number, size: number }) {
if (typeof urlOrData === 'string') {
return await ctx.runTask(ctx.fetch({ url: urlOrData, headers: [['Range', `bytes=${range.offset}-${range.offset + range.size - 1}`]], type: 'binary' }));
return await ctx.runTask(ctx.fetch({ url: urlOrData, headers: { 'Range': `bytes=${range.offset}-${range.offset + range.size - 1}` }, type: 'binary' }));
} else {
return urlOrData.slice(range.offset, range.offset + range.size);
}

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

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

@@ -675,7 +675,8 @@ namespace Canvas3D {
const xrChanged = xrManager.update(xrFrame);
if (!xrChanged && xrFrame) return false;
const shouldRender = force || cameraChanged || resized || forceNextRender || xrChanged;
const activeAnimation = renderer.props.enableAnimation && scene.hasAnimation;
const shouldRender = force || cameraChanged || resized || forceNextRender || xrChanged || activeAnimation;
forceNextRender = false;
if (passes.illumination.supported && p.illumination.enabled && !xrFrame) {
@@ -754,6 +755,7 @@ namespace Canvas3D {
if (webgl.xr.session && !options?.xrFrame) return;
currentTime = t;
renderer.setTime((currentTime - startTime) / 1000);
commit(options?.isSynchronous);
// update the controler before the camera transition

View File

@@ -121,7 +121,7 @@ export class PointerHelper {
this.camera = new Camera();
this.shape = getPointerMeshShape(this.getData(), this.props, this.shape);
this.shape = getPointerMeshShape(this.getData(), this.props);
this.renderObject = createMeshRenderObject(this.shape, this.props);
this.scene.add(this.renderObject);
}

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

@@ -0,0 +1,64 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ValueCell } from '../../mol-util';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
export type AnimationData = {
uWiggleSpeed: ValueCell<number>,
uWiggleAmplitude: ValueCell<number>,
uWiggleFrequency: ValueCell<number>,
uWiggleMode: ValueCell<number>,
uTumbleSpeed: ValueCell<number>,
uTumbleAmplitude: ValueCell<number>,
uTumbleFrequency: ValueCell<number>,
}
export function getAnimationParam() {
return PD.Group({
wiggleMode: PD.Select('position', [['position', 'Position'], ['group', 'Group']] as const, { description: 'Noise seeding mode. Position: spatially correlated (nearby atoms move together). Group: per-group independent noise.' }),
wiggleSpeed: PD.Numeric(7, { min: 0, max: 10, step: 0.1 }, { description: 'Speed of vertex wiggle animation.' }),
wiggleAmplitude: PD.Numeric(0, { min: 0, max: 5, step: 0.01 }, { description: 'Amplitude of vertex wiggle animation.' }),
wiggleFrequency: PD.Numeric(0.2, { min: 0.01, max: 2, step: 0.01 }, { description: 'Spatial frequency of vertex wiggle noise (position mode). Lower values correlate nearby atoms more.' }),
tumbleSpeed: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }, { description: 'Speed of instance tumble animation.' }),
tumbleAmplitude: PD.Numeric(0, { min: 0, max: 10, step: 0.1 }, { description: 'Amplitude of instance tumble animation. In Ångströms of implied surface displacement.' }),
tumbleFrequency: PD.Numeric(0.2, { min: 0, max: 2, step: 0.01 }, { description: 'Spatial frequency multiplier for tumble noise.' }),
});
}
export type AnimationParam = ReturnType<typeof getAnimationParam>
export type AnimationProps = AnimationParam['defaultValue'];
export function areAnimationPropsEqual(a: AnimationProps, b: AnimationProps): boolean {
return a.wiggleMode === b.wiggleMode
&& a.wiggleSpeed === b.wiggleSpeed
&& a.wiggleAmplitude === b.wiggleAmplitude
&& a.wiggleFrequency === b.wiggleFrequency
&& a.tumbleSpeed === b.tumbleSpeed
&& a.tumbleAmplitude === b.tumbleAmplitude
&& a.tumbleFrequency === b.tumbleFrequency;
}
export function createAnimationValues(props: AnimationProps) {
return {
uWiggleSpeed: ValueCell.create(props.wiggleSpeed),
uWiggleAmplitude: ValueCell.create(props.wiggleAmplitude),
uWiggleFrequency: ValueCell.create(props.wiggleFrequency),
uWiggleMode: ValueCell.create(props.wiggleMode === 'position' ? 0 : 1),
uTumbleSpeed: ValueCell.create(props.tumbleSpeed),
uTumbleAmplitude: ValueCell.create(props.tumbleAmplitude),
uTumbleFrequency: ValueCell.create(props.tumbleFrequency),
};
}
export function updateAnimationValues(values: AnimationData, props: AnimationProps) {
ValueCell.updateIfChanged(values.uWiggleSpeed, props.wiggleSpeed);
ValueCell.updateIfChanged(values.uWiggleAmplitude, props.wiggleAmplitude);
ValueCell.updateIfChanged(values.uWiggleFrequency, props.wiggleFrequency);
ValueCell.updateIfChanged(values.uWiggleMode, props.wiggleMode === 'position' ? 0 : 1);
ValueCell.updateIfChanged(values.uTumbleSpeed, props.tumbleSpeed);
ValueCell.updateIfChanged(values.uTumbleAmplitude, props.tumbleAmplitude);
ValueCell.updateIfChanged(values.uTumbleFrequency, props.tumbleFrequency);
}

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';
@@ -28,7 +28,9 @@ import { CylindersValues } from '../../../mol-gl/renderable/cylinders';
import { RenderableState } from '../../../mol-gl/renderable';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
import { getInteriorParam, updateInteriorValues, createInteriorValues } from '../interior';
import { getAnimationParam, createAnimationValues, updateAnimationValues } from '../animation';
export interface Cylinders {
readonly kind: 'cylinders',
@@ -180,6 +182,7 @@ export namespace Cylinders {
bumpFrequency: PD.Numeric(0, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
bumpAmplitude: PD.Numeric(1, { min: 0, max: 5, step: 0.1 }, BaseGeometry.ShadingCategory),
interior: getInteriorParam(),
animation: getAnimationParam(),
colorMode: PD.Select('default', PD.arrayToOptions(['default', 'interpolate'] as const), BaseGeometry.ShadingCategory)
};
export type Params = typeof Params
@@ -222,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();
@@ -230,6 +233,7 @@ export namespace Cylinders {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: cylinders.cylinderCount * 4 * 3, vertexCount: cylinders.cylinderCount * 6, groupCount, instanceCount };
@@ -258,6 +262,7 @@ export namespace Cylinders {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
padding: ValueCell.create(padding),
@@ -275,6 +280,7 @@ export namespace Cylinders {
dDualColor: ValueCell.create(props.colorMode === 'interpolate'),
...createInteriorValues(props.interior),
...createAnimationValues(props.animation),
};
}
@@ -297,6 +303,7 @@ export namespace Cylinders {
ValueCell.updateIfChanged(values.uBumpAmplitude, props.bumpAmplitude);
ValueCell.updateIfChanged(values.dDualColor, props.colorMode === 'interpolate');
updateInteriorValues(values, props.interior);
updateAnimationValues(values, props.animation);
}
function updateBoundingSphere(values: CylindersValues, cylinders: Cylinders) {

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';
@@ -29,6 +29,7 @@ import { createEmptyClipping } from '../clipping-data';
import { Grid } from '../../../mol-model/volume';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
const VolumeBox = Box();
@@ -227,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();
@@ -235,6 +236,7 @@ export namespace DirectVolume {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const [x, y, z] = gridDimension.ref.value;
const counts = { drawCount: VolumeBox.indices.length, vertexCount: x * y * z, groupCount, instanceCount };
@@ -255,6 +257,7 @@ export namespace DirectVolume {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
...BaseGeometry.createValues(props, counts),

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';
@@ -28,6 +28,7 @@ import { NullLocation } from '../../../mol-model/location';
import { QuadPositions } from '../../../mol-gl/compute/util';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
const QuadIndices = new Uint32Array([
0, 1, 2,
@@ -200,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();
@@ -208,6 +209,7 @@ namespace Image {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: QuadIndices.length, vertexCount: QuadPositions.length / 3, groupCount, instanceCount };
@@ -224,6 +226,7 @@ namespace Image {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
...BaseGeometry.createValues(props, counts),

View File

@@ -21,13 +21,15 @@ 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';
import { createEmptyClipping } from '../clipping-data';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
import { getAnimationParam, createAnimationValues, updateAnimationValues } from '../animation';
/** Wide line */
export interface Lines {
@@ -188,6 +190,7 @@ export namespace Lines {
...BaseGeometry.Params,
sizeFactor: PD.Numeric(2, { min: 0, max: 10, step: 0.1 }),
lineSizeAttenuation: PD.Boolean(false),
animation: getAnimationParam(),
};
export type Params = typeof Params
@@ -229,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();
@@ -237,6 +240,7 @@ export namespace Lines {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: lines.lineCount * 2 * 3, vertexCount: lines.vertexCount, groupCount, instanceCount };
@@ -262,6 +266,7 @@ export namespace Lines {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
...BaseGeometry.createValues(props, counts),
@@ -269,6 +274,7 @@ export namespace Lines {
dLineSizeAttenuation: ValueCell.create(props.lineSizeAttenuation),
uDoubleSided: ValueCell.create(true),
dFlipSided: ValueCell.create(false),
...createAnimationValues(props.animation),
stripCount: lines.stripCount,
stripOffsets: lines.stripBuffer,
@@ -285,6 +291,7 @@ export namespace Lines {
BaseGeometry.updateValues(values, props);
ValueCell.updateIfChanged(values.uSizeFactor, props.sizeFactor);
ValueCell.updateIfChanged(values.dLineSizeAttenuation, props.lineSizeAttenuation);
updateAnimationValues(values, props.animation);
}
function updateBoundingSphere(values: LinesValues, lines: Lines) {

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';
@@ -29,7 +29,9 @@ import { arraySetAdd } from '../../../mol-util/array';
import { degToRad } from '../../../mol-math/misc';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
import { createInteriorValues, getInteriorParam, updateInteriorValues } from '../interior';
import { getAnimationParam, createAnimationValues, updateAnimationValues } from '../animation';
export interface Mesh {
readonly kind: 'mesh',
@@ -639,6 +641,7 @@ export namespace Mesh {
bumpFrequency: PD.Numeric(0, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
bumpAmplitude: PD.Numeric(1, { min: 0, max: 5, step: 0.1 }, BaseGeometry.ShadingCategory),
interior: getInteriorParam(),
animation: getAnimationParam(),
};
export type Params = typeof Params
@@ -681,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();
@@ -689,6 +692,7 @@ export namespace Mesh {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: mesh.triangleCount * 3, vertexCount: mesh.vertexCount, groupCount, instanceCount };
@@ -713,6 +717,7 @@ export namespace Mesh {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
...BaseGeometry.createValues(props, counts),
@@ -729,6 +734,7 @@ export namespace Mesh {
meta: ValueCell.create(mesh.meta),
...createInteriorValues(props.interior),
...createAnimationValues(props.animation),
};
}
@@ -750,6 +756,7 @@ export namespace Mesh {
ValueCell.updateIfChanged(values.uBumpFrequency, props.bumpFrequency);
ValueCell.updateIfChanged(values.uBumpAmplitude, props.bumpAmplitude);
updateInteriorValues(values, props.interior);
updateAnimationValues(values, props.animation);
}
function updateBoundingSphere(values: MeshValues, mesh: Mesh) {

View File

@@ -20,13 +20,15 @@ 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';
import { createEmptyClipping } from '../clipping-data';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
import { getAnimationParam, createAnimationValues, updateAnimationValues } from '../animation';
/** Point cloud */
export interface Points {
@@ -136,6 +138,7 @@ export namespace Points {
sizeFactor: PD.Numeric(3, { min: 0, max: 10, step: 0.1 }),
pointSizeAttenuation: PD.Boolean(false),
pointStyle: PD.Select('square', PD.objectToOptions(StyleTypes)),
animation: getAnimationParam(),
};
export type Params = typeof Params
@@ -175,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();
@@ -183,6 +186,7 @@ export namespace Points {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: points.pointCount, vertexCount: points.pointCount, groupCount, instanceCount };
@@ -205,12 +209,14 @@ export namespace Points {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
...BaseGeometry.createValues(props, counts),
uSizeFactor: ValueCell.create(props.sizeFactor),
dPointSizeAttenuation: ValueCell.create(props.pointSizeAttenuation),
dPointStyle: ValueCell.create(props.pointStyle),
...createAnimationValues(props.animation),
};
}
@@ -225,6 +231,7 @@ export namespace Points {
ValueCell.updateIfChanged(values.uSizeFactor, props.sizeFactor);
ValueCell.updateIfChanged(values.dPointSizeAttenuation, props.pointSizeAttenuation);
ValueCell.updateIfChanged(values.dPointStyle, props.pointStyle);
updateAnimationValues(values, props.animation);
}
function updateBoundingSphere(values: PointsValues, points: Points) {

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';
@@ -27,7 +27,9 @@ import { Vec2, Vec3, Vec4 } from '../../../mol-math/linear-algebra';
import { RenderableState } from '../../../mol-gl/renderable';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
import { createInteriorValues, getInteriorParam, updateInteriorValues } from '../interior';
import { getAnimationParam, createAnimationValues, updateAnimationValues } from '../animation';
export interface Spheres {
readonly kind: 'spheres',
@@ -247,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 }),
@@ -262,6 +291,7 @@ export namespace Spheres {
bumpFrequency: PD.Numeric(0, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
bumpAmplitude: PD.Numeric(1, { min: 0, max: 5, step: 0.1 }, BaseGeometry.ShadingCategory),
interior: getInteriorParam(),
animation: getAnimationParam(),
lodLevels: PD.ObjectList({
minDistance: PD.Numeric(0),
maxDistance: PD.Numeric(0),
@@ -270,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
@@ -311,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();
@@ -319,6 +350,7 @@ export namespace Spheres {
const emissive = createEmptyEmissive();
const material = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: spheres.sphereCount * 2 * 3, vertexCount: spheres.sphereCount * 6, groupCount, instanceCount };
@@ -345,6 +377,7 @@ export namespace Spheres {
...emissive,
...material,
...clipping,
...wiggle,
...transform,
padding: ValueCell.create(padding),
@@ -368,6 +401,7 @@ export namespace Spheres {
groupBuffer: spheres.groupBuffer,
...createInteriorValues(props.interior),
...createAnimationValues(props.animation),
};
}
@@ -392,6 +426,7 @@ export namespace Spheres {
ValueCell.updateIfChanged(values.uBumpFrequency, props.bumpFrequency);
ValueCell.updateIfChanged(values.uBumpAmplitude, props.bumpAmplitude);
updateInteriorValues(values, props.interior);
updateAnimationValues(values, props.animation);
const lodLevels = getLodLevels(values.lodLevels.ref.value as LodLevelsValue);
if (!areLodLevelsEqual(props.lodLevels, lodLevels)) {

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';
@@ -33,6 +33,7 @@ import { GroupMapping, createGroupMapping } from '../../util';
import { createEmptyClipping } from '../clipping-data';
import { createEmptySubstance } from '../substance-data';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
type TextAttachment = (
'bottom-left' | 'bottom-center' | 'bottom-right' |
@@ -218,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();
@@ -226,6 +227,7 @@ export namespace Text {
const emissive = createEmptyEmissive();
const substance = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: text.charCount * 2 * 3, vertexCount: text.charCount * 4, groupCount, instanceCount };
@@ -253,6 +255,7 @@ export namespace Text {
...emissive,
...substance,
...clipping,
...wiggle,
...transform,
aTexCoord: text.tcoordBuffer,

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';
@@ -28,7 +28,9 @@ import { createEmptySubstance } from '../substance-data';
import { RenderableState } from '../../../mol-gl/renderable';
import { WebGLContext } from '../../../mol-gl/webgl/context';
import { createEmptyEmissive } from '../emissive-data';
import { createEmptyWiggle } from '../wiggle-data';
import { createInteriorValues, getInteriorParam, updateInteriorValues } from '../interior';
import { getAnimationParam, createAnimationValues, updateAnimationValues } from '../animation';
export interface TextureMesh {
readonly kind: 'texture-mesh',
@@ -130,6 +132,7 @@ export namespace TextureMesh {
bumpFrequency: PD.Numeric(0, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
bumpAmplitude: PD.Numeric(1, { min: 0, max: 5, step: 0.1 }, BaseGeometry.ShadingCategory),
interior: getInteriorParam(),
animation: getAnimationParam(),
};
export type Params = typeof Params
@@ -200,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();
@@ -208,6 +211,7 @@ export namespace TextureMesh {
const emissive = createEmptyEmissive();
const substance = createEmptySubstance();
const clipping = createEmptyClipping();
const wiggle = createEmptyWiggle();
const counts = { drawCount: textureMesh.vertexCount, vertexCount: textureMesh.vertexCount, groupCount, instanceCount };
@@ -234,6 +238,7 @@ export namespace TextureMesh {
...emissive,
...substance,
...clipping,
...wiggle,
...transform,
...BaseGeometry.createValues(props, counts),
@@ -250,6 +255,7 @@ export namespace TextureMesh {
meta: ValueCell.create(textureMesh.meta),
...createInteriorValues(props.interior),
...createAnimationValues(props.animation),
};
}
@@ -271,6 +277,7 @@ export namespace TextureMesh {
ValueCell.updateIfChanged(values.uBumpFrequency, props.bumpFrequency);
ValueCell.updateIfChanged(values.uBumpAmplitude, props.bumpAmplitude);
updateInteriorValues(values, props.interior);
updateAnimationValues(values, props.animation);
}
function updateBoundingSphere(values: TextureMeshValues, textureMesh: TextureMesh) {

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ValueCell } from '../../mol-util/value-cell';
import { Vec2 } from '../../mol-math/linear-algebra';
import { TextureImage, createTextureImage } from '../../mol-gl/renderable/util';
export type WiggleType = 'instance' | 'groupInstance';
export type WiggleData = {
tWiggle: ValueCell<TextureImage<Uint8Array>>
uWiggleTexDim: ValueCell<Vec2>
dWiggle: ValueCell<boolean>,
wiggleAverage: ValueCell<number>,
dWiggleType: ValueCell<string>,
uWiggleStrength: ValueCell<number>,
}
export function applyWiggleValue(array: Uint8Array, start: number, end: number, value: number) {
for (let i = start; i < end; ++i) {
array[i] = value * 255;
}
return true;
}
export function getWiggleAverage(array: Uint8Array, count: number): number {
if (count === 0 || array.length < count) return 0;
let sum = 0;
for (let i = 0; i < count; ++i) {
sum += array[i];
}
return sum / (255 * count);
}
export function clearWiggle(array: Uint8Array, start: number, end: number) {
array.fill(0, start, end);
}
export function createWiggle(count: number, type: WiggleType, wiggleData?: WiggleData): WiggleData {
const wiggle = createTextureImage(Math.max(1, count), 1, Uint8Array, wiggleData && wiggleData.tWiggle.ref.value.array);
if (wiggleData) {
ValueCell.update(wiggleData.tWiggle, wiggle);
ValueCell.update(wiggleData.uWiggleTexDim, Vec2.create(wiggle.width, wiggle.height));
ValueCell.updateIfChanged(wiggleData.dWiggle, count > 0);
ValueCell.updateIfChanged(wiggleData.wiggleAverage, getWiggleAverage(wiggle.array, count));
ValueCell.updateIfChanged(wiggleData.dWiggleType, type);
return wiggleData;
} else {
return {
tWiggle: ValueCell.create(wiggle),
uWiggleTexDim: ValueCell.create(Vec2.create(wiggle.width, wiggle.height)),
dWiggle: ValueCell.create(count > 0),
wiggleAverage: ValueCell.create(0),
dWiggleType: ValueCell.create(type),
uWiggleStrength: ValueCell.create(1),
};
}
}
const emptyWiggleTexture = { array: new Uint8Array(1), width: 1, height: 1 };
export function createEmptyWiggle(wiggleData?: WiggleData): WiggleData {
if (wiggleData) {
ValueCell.update(wiggleData.tWiggle, emptyWiggleTexture);
ValueCell.update(wiggleData.uWiggleTexDim, Vec2.create(1, 1));
return wiggleData;
} else {
return {
tWiggle: ValueCell.create(emptyWiggleTexture),
uWiggleTexDim: ValueCell.create(Vec2.create(1, 1)),
dWiggle: ValueCell.create(false),
wiggleAverage: ValueCell.create(0),
dWiggleType: ValueCell.create('groupInstance'),
uWiggleStrength: ValueCell.create(1),
};
}
}

View File

@@ -52,7 +52,7 @@ describe('renderer', () => {
scene.add(points);
scene.commit();
expect(ctx.stats.resourceCounts.attribute).toBe(ctx.isWebGL2 ? 4 : 5);
expect(ctx.stats.resourceCounts.texture).toBe(10);
expect(ctx.stats.resourceCounts.texture).toBe(11);
expect(ctx.stats.resourceCounts.vertexArray).toBe(ctx.extensions.vertexArrayObject ? 5 : 0);
expect(ctx.stats.resourceCounts.program).toBe(5);
expect(ctx.stats.resourceCounts.shader).toBe(10);
@@ -89,7 +89,7 @@ describe('renderer', () => {
sceneDpoit.commit();
expect(ctx.stats.resourceCounts.attribute).toBe(ctx.isWebGL2 ? 12 : 15);
expect(ctx.stats.resourceCounts.texture).toBe(28);
expect(ctx.stats.resourceCounts.texture).toBe(31);
expect(ctx.stats.resourceCounts.vertexArray).toBe(ctx.extensions.vertexArrayObject ? 15 : 0);
expect(ctx.stats.resourceCounts.program).toBe(7);
expect(ctx.stats.resourceCounts.shader).toBe(14);

View File

@@ -7,7 +7,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, Values, InternalSchema, SizeSchema, InternalValues, ElementsSpec, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, InteriorSchema } from './schema';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, Values, InternalSchema, SizeSchema, InternalValues, ElementsSpec, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, InteriorSchema, AnimationSchema } from './schema';
import { CylindersShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -35,6 +35,7 @@ export const CylindersSchema = {
dDualColor: DefineSpec('boolean'),
...InteriorSchema,
...AnimationSchema,
};
export type CylindersSchema = typeof CylindersSchema
export type CylindersValues = Values<CylindersSchema>

View File

@@ -7,7 +7,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, DefineSpec, Values, InternalSchema, SizeSchema, ElementsSpec, InternalValues, GlobalTextureSchema, UniformSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, ValueSpec } from './schema';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, DefineSpec, Values, InternalSchema, SizeSchema, ElementsSpec, InternalValues, GlobalTextureSchema, UniformSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, ValueSpec, AnimationSchema } from './schema';
import { ValueCell } from '../../mol-util';
import { LinesShaderCode } from '../shader-code';
@@ -24,6 +24,8 @@ export const LinesSchema = {
dFlipSided: DefineSpec('boolean'),
stripCount: ValueSpec('number'),
stripOffsets: ValueSpec('uint32'),
...AnimationSchema,
};
export type LinesSchema = typeof LinesSchema
export type LinesValues = Values<LinesSchema>

View File

@@ -7,7 +7,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, ElementsSpec, DefineSpec, Values, InternalSchema, InternalValues, GlobalTextureSchema, ValueSpec, UniformSpec, GlobalDefineSchema, GlobalDefineValues, GlobalDefines, InteriorSchema } from './schema';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, ElementsSpec, DefineSpec, Values, InternalSchema, InternalValues, GlobalTextureSchema, ValueSpec, UniformSpec, GlobalDefineSchema, GlobalDefineValues, GlobalDefines, InteriorSchema, AnimationSchema } from './schema';
import { MeshShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -30,6 +30,7 @@ export const MeshSchema = {
meta: ValueSpec('unknown'),
...InteriorSchema,
...AnimationSchema,
} as const;
export type MeshSchema = typeof MeshSchema
export type MeshValues = Values<MeshSchema>

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 Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -7,7 +7,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, DefineSpec, Values, InternalSchema, SizeSchema, InternalValues, GlobalTextureSchema, GlobalDefineValues, GlobalDefines, GlobalDefineSchema } from './schema';
import { GlobalUniformSchema, BaseSchema, AttributeSpec, DefineSpec, Values, InternalSchema, SizeSchema, InternalValues, GlobalTextureSchema, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, AnimationSchema } from './schema';
import { PointsShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -18,6 +18,8 @@ export const PointsSchema = {
aPosition: AttributeSpec('float32', 3, 0),
dPointSizeAttenuation: DefineSpec('boolean'),
dPointStyle: DefineSpec('string', ['square', 'circle', 'fuzzy']),
...AnimationSchema,
};
export type PointsSchema = typeof PointsSchema
export type PointsValues = Values<PointsSchema>

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 Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
@@ -179,6 +179,9 @@ export const GlobalUniformSchema = {
uMarkingDepthTest: UniformSpec('b'),
uMarkingType: UniformSpec('i'),
uPickType: UniformSpec('i'),
uTime: UniformSpec('f'),
uEnableAnimation: UniformSpec('b'),
} as const;
export type GlobalUniformSchema = typeof GlobalUniformSchema
export type GlobalUniformValues = Values<GlobalUniformSchema>
@@ -315,6 +318,17 @@ export const ClippingSchema = {
export type ClippingSchema = typeof ClippingSchema
export type ClippingValues = Values<ClippingSchema>
export const WiggleSchema = {
uWiggleTexDim: UniformSpec('v2'),
tWiggle: TextureSpec('image-uint8', 'alpha', 'ubyte', 'nearest'),
dWiggle: DefineSpec('boolean'),
wiggleAverage: ValueSpec('number'),
dWiggleType: DefineSpec('string', ['instance', 'groupInstance']),
uWiggleStrength: UniformSpec('f', 'material'),
} as const;
export type WiggleSchema = typeof WiggleSchema
export type WiggleValues = Values<WiggleSchema>
export const BaseSchema = {
dGeometryType: DefineSpec('string', ['cylinders', 'directVolume', 'image', 'lines', 'mesh', 'points', 'spheres', 'text', 'textureMesh']),
@@ -325,6 +339,7 @@ export const BaseSchema = {
...EmissiveSchema,
...SubstanceSchema,
...ClippingSchema,
...WiggleSchema,
dClipObjectCount: DefineSpec('number'),
dClipVariant: DefineSpec('string', ['instance', 'pixel']),
@@ -395,3 +410,15 @@ export const InteriorSchema = {
} as const;
export type InteriorSchema = typeof InteriorSchema
export type InteriorValues = Values<InteriorSchema>
export const AnimationSchema = {
uWiggleSpeed: UniformSpec('f', 'material'),
uWiggleAmplitude: UniformSpec('f', 'material'),
uWiggleFrequency: UniformSpec('f', 'material'),
uWiggleMode: UniformSpec('i', 'material'),
uTumbleSpeed: UniformSpec('f', 'material'),
uTumbleAmplitude: UniformSpec('f', 'material'),
uTumbleFrequency: UniformSpec('f', 'material'),
} as const;
export type AnimationSchema = typeof AnimationSchema
export type AnimationValues = Values<AnimationSchema>

View File

@@ -7,7 +7,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, Values, InternalSchema, SizeSchema, InternalValues, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec, TextureSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, InteriorSchema } from './schema';
import { GlobalUniformSchema, BaseSchema, Values, InternalSchema, SizeSchema, InternalValues, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec, TextureSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, InteriorSchema, AnimationSchema } from './schema';
import { SpheresShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -36,6 +36,7 @@ export const SpheresSchema = {
groupBuffer: ValueSpec('float32'),
...InteriorSchema,
...AnimationSchema,
};
export type SpheresSchema = typeof SpheresSchema
export type SpheresValues = Values<SpheresSchema>

View File

@@ -7,7 +7,7 @@
import { Renderable, RenderableState, createRenderable } from '../renderable';
import { WebGLContext } from '../webgl/context';
import { createGraphicsRenderItem, Transparency } from '../webgl/render-item';
import { GlobalUniformSchema, BaseSchema, DefineSpec, Values, InternalSchema, InternalValues, UniformSpec, TextureSpec, GlobalTextureSchema, ValueSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, InteriorSchema } from './schema';
import { GlobalUniformSchema, BaseSchema, DefineSpec, Values, InternalSchema, InternalValues, UniformSpec, TextureSpec, GlobalTextureSchema, ValueSpec, GlobalDefineValues, GlobalDefines, GlobalDefineSchema, InteriorSchema, AnimationSchema } from './schema';
import { MeshShaderCode } from '../shader-code';
import { ValueCell } from '../../mol-util';
@@ -30,6 +30,7 @@ export const TextureMeshSchema = {
meta: ValueSpec('unknown'),
...InteriorSchema,
...AnimationSchema,
};
export type TextureMeshSchema = typeof TextureMeshSchema
export type TextureMeshValues = Values<TextureMeshSchema>

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 Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
@@ -63,6 +63,7 @@ interface Renderer {
clear: (toBackgroundColor: boolean, ignoreTransparentBackground?: boolean, forceToTransparency?: boolean) => void
clearDepth: (packed?: boolean) => void
update: (camera: ICamera, scene: Scene) => void
setTime: (time: number) => void
renderPick: (group: Scene.Group, camera: ICamera, variant: 'pick' | 'depth', pickType: PickType) => void
renderDepth: (group: Scene.Group, camera: ICamera) => void
@@ -121,6 +122,8 @@ export const RendererParams = {
}] }),
ambientColor: PD.Color(Color.fromNormalizedRgb(1.0, 1.0, 1.0)),
ambientIntensity: PD.Numeric(0.4, { min: 0.0, max: 2.0, step: 0.01 }),
enableAnimation: PD.Boolean(true, { description: 'Enable time-based animations.' }),
};
export type RendererProps = PD.Values<typeof RendererParams>
@@ -277,6 +280,9 @@ namespace Renderer {
uXrayEdgeFalloff: ValueCell.create(p.xrayEdgeFalloff),
uCelSteps: ValueCell.create(p.celSteps),
uExposure: ValueCell.create(p.exposure),
uTime: ValueCell.create(0),
uEnableAnimation: ValueCell.create(p.enableAnimation),
};
const globalUniformList = Object.entries(globalUniforms);
@@ -829,6 +835,9 @@ namespace Renderer {
renderWboitTransparent,
renderDpoitTransparent,
setTime: (time: number) => {
ValueCell.updateIfChanged(globalUniforms.uTime, time);
},
setProps: (props: Partial<RendererProps>) => {
if (props.backgroundColor !== undefined && props.backgroundColor !== p.backgroundColor) {
p.backgroundColor = props.backgroundColor;
@@ -904,6 +913,11 @@ namespace Renderer {
Vec3.scale(ambientColor, Color.toArrayNormalized(p.ambientColor, ambientColor, 0), p.ambientIntensity);
ValueCell.update(globalUniforms.uAmbientColor, ambientColor);
}
if (props.enableAnimation !== undefined && props.enableAnimation !== p.enableAnimation) {
p.enableAnimation = props.enableAnimation;
ValueCell.update(globalUniforms.uEnableAnimation, p.enableAnimation);
}
},
setViewport: (x: number, y: number, width: number, height: number) => {
state.viewport(x, y, width, height);

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 Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
@@ -92,12 +92,16 @@ interface Scene extends Object3D {
readonly markerAverage: number
/** Emissive average of primitive renderables */
readonly emissiveAverage: number
/** Wiggle average of primitive renderables */
readonly wiggleAverage: number
/** Opacity average of primitive renderables */
readonly opacityAverage: number
/** Transparency minimum, excluding fully opaque, of primitive renderables */
readonly transparencyMin: number
/** Is `true` if any primitive renderable (possibly) has any opaque part */
readonly hasOpaque: boolean
/** Is `true` if any primitive renderable has animation enabled */
readonly hasAnimation: boolean
}
namespace Scene {
@@ -119,15 +123,19 @@ namespace Scene {
let markerAverageDirty = true;
let emissiveAverageDirty = true;
let wiggleAverageDirty = true;
let opacityAverageDirty = true;
let transparencyMinDirty = true;
let hasOpaqueDirty = true;
let hasAnimationDirty = true;
let markerAverage = 0;
let emissiveAverage = 0;
let wiggleAverage = 0;
let opacityAverage = 0;
let transparencyMin = 0;
let hasOpaque = false;
let hasAnimation = false;
const object3d = Object3D.create();
const { view, position, direction, up } = object3d;
@@ -185,9 +193,11 @@ namespace Scene {
renderables.sort(renderableSort);
markerAverageDirty = true;
emissiveAverageDirty = true;
wiggleAverageDirty = true;
opacityAverageDirty = true;
transparencyMinDirty = true;
hasOpaqueDirty = true;
hasAnimationDirty = true;
return true;
}
@@ -211,9 +221,11 @@ namespace Scene {
boundingSphereVisibleDirty = true;
markerAverageDirty = true;
emissiveAverageDirty = true;
wiggleAverageDirty = true;
opacityAverageDirty = true;
transparencyMinDirty = true;
hasOpaqueDirty = true;
hasAnimationDirty = true;
visibleHash = newVisibleHash;
return true;
} else {
@@ -245,6 +257,18 @@ namespace Scene {
return count > 0 ? emissiveAverage / count : 0;
}
function calculateWiggleAverage() {
if (primitives.length === 0) return 0;
let count = 0;
let wiggleAverage = 0;
for (let i = 0, il = primitives.length; i < il; ++i) {
if (!primitives[i].state.visible) continue;
wiggleAverage += primitives[i].values.wiggleAverage.ref.value;
count += 1;
}
return count > 0 ? wiggleAverage / count : 0;
}
function calculateOpacityAverage() {
if (primitives.length === 0) return 0;
let count = 0;
@@ -301,6 +325,22 @@ namespace Scene {
return false;
}
function calculateHasAnimation() {
for (let i = 0, il = primitives.length; i < il; ++i) {
const p = primitives[i];
if (!p.state.visible) continue;
if ((p.values.uWiggleAmplitude?.ref.value > 0 || p.values.wiggleAverage.ref.value > 0) &&
p.values.uWiggleSpeed?.ref.value > 0 &&
p.values.uWiggleFrequency?.ref.value > 0) return true;
if (p.values.uTumbleAmplitude?.ref.value > 0 &&
p.values.uTumbleSpeed?.ref.value > 0 &&
p.values.uTumbleFrequency?.ref.value > 0) return true;
}
return false;
}
return {
view, position, direction, up,
@@ -341,9 +381,11 @@ namespace Scene {
}
markerAverageDirty = true;
emissiveAverageDirty = true;
wiggleAverageDirty = true;
opacityAverageDirty = true;
transparencyMinDirty = true;
hasOpaqueDirty = true;
hasAnimationDirty = true;
},
add: (o: GraphicsRenderObject) => commitQueue.add(o),
remove: (o: GraphicsRenderObject) => commitQueue.remove(o),
@@ -401,6 +443,13 @@ namespace Scene {
}
return emissiveAverage;
},
get wiggleAverage() {
if (wiggleAverageDirty) {
wiggleAverage = calculateWiggleAverage();
wiggleAverageDirty = false;
}
return wiggleAverage;
},
get opacityAverage() {
if (opacityAverageDirty) {
opacityAverage = calculateOpacityAverage();
@@ -422,6 +471,13 @@ namespace Scene {
}
return hasOpaque;
},
get hasAnimation() {
if (hasAnimationDirty) {
hasAnimation = calculateHasAnimation();
hasAnimationDirty = false;
}
return hasAnimation;
},
};
}
}

View File

@@ -62,6 +62,7 @@ import { clip_instance } from './shader/chunks/clip-instance.glsl';
import { clip_pixel } from './shader/chunks/clip-pixel.glsl';
import { color_frag_params } from './shader/chunks/color-frag-params.glsl';
import { color_vert_params } from './shader/chunks/color-vert-params.glsl';
import { common_animation } from './shader/chunks/common-animation.glsl';
import { common_clip } from './shader/chunks/common-clip.glsl';
import { common_frag_params } from './shader/chunks/common-frag-params.glsl';
import { common_vert_params } from './shader/chunks/common-vert-params.glsl';
@@ -97,6 +98,7 @@ const ShaderChunks: { [k: string]: string } = {
clip_pixel,
color_frag_params,
color_vert_params,
common_animation,
common_clip,
common_frag_params,
common_vert_params,

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

@@ -1,11 +1,19 @@
export const assign_position = `
mat4 model = uModel * aTransform;
#ifdef dGeometryType_image
mat4 transform = aTransform;
#else
mat4 transform = applyTumble(aTransform, aInstance, float(uObjectId));
#endif
mat4 model = uModel * transform;
mat4 modelView = uView * model;
#ifdef dGeometryType_textureMesh
vec3 position = readFromTexture(tPosition, vertexId, uGeoTexDim).xyz;
#else
vec3 position = aPosition;
#endif
#ifndef dGeometryType_image
position = applyWiggle(position, group, aInstance);
#endif
vec4 position4 = vec4(position, 1.0);
// for accessing tColorGrid in vert shader and for clipping in frag shader
vModelPosition = (model * position4).xyz;

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
export const common_animation = `
uniform float uWiggleSpeed;
uniform float uWiggleAmplitude;
uniform float uWiggleFrequency;
uniform int uWiggleMode;
uniform float uTumbleSpeed;
uniform float uTumbleAmplitude;
uniform float uTumbleFrequency;
#ifdef dWiggle
uniform vec2 uWiggleTexDim;
uniform sampler2D tWiggle;
uniform float uWiggleStrength;
#endif
vec3 applyWiggle(vec3 pos, float groupId, float instanceId) {
if (!uEnableAnimation) return pos;
float amplitude = uWiggleAmplitude;
#ifdef dWiggle
#if defined(dWiggleType_instance)
amplitude += readFromTexture(tWiggle, instanceId, uWiggleTexDim).a * uWiggleStrength;
#elif defined(dWiggleType_groupInstance)
amplitude += readFromTexture(tWiggle, instanceId * float(uGroupCount) + groupId, uWiggleTexDim).a * uWiggleStrength;
#endif
#endif
if (amplitude > 0.0 && uWiggleSpeed > 0.0 && uWiggleFrequency > 0.0) {
float t = uTime * uWiggleSpeed;
vec3 s;
if (uWiggleMode == 0) {
// Position mode: spatial position correlates nearby atoms
s = pos;
} else {
// Group mode: per-group independent noise
// Hash groupId into a well-distributed 3D seed to avoid repetition
s = vec3(
fract(sin(groupId * 127.1) * 43758.5453) * 1000.0,
fract(sin(groupId * 269.5) * 21639.7182) * 1000.0,
fract(sin(groupId * 419.2) * 32517.3926) * 1000.0
);
}
s *= uWiggleFrequency;
pos.x += (fbm(vec3(s.x, s.y + t, s.z)) / 0.4375 - 1.0) * amplitude;
pos.y += (fbm(vec3(s.x + 37.0, s.y, s.z + t)) / 0.4375 - 1.0) * amplitude;
pos.z += (fbm(vec3(s.x + t, s.y + 73.0, s.z)) / 0.4375 - 1.0) * amplitude;
}
return pos;
}
mat4 applyTumble(mat4 transform, float instanceIndex, float objectId) {
if (!uEnableAnimation) return transform;
if (uTumbleAmplitude > 0.0 && uTumbleSpeed > 0.0 && uTumbleFrequency > 0.0) {
// Scale amplitude inversely with bounding-sphere radius (Stokes-Einstein: D ~ 1/r)
float amplitude = uTumbleAmplitude / max(uInvariantBoundingSphere.w, 1.0);
float t = uTime * uTumbleSpeed;
float seed = (instanceIndex * 127.1 + objectId * 311.7) * uTumbleFrequency;
// Per-instance rotation angles from layered noise (Brownian-like)
float angleX = (fbm(vec3(seed, t, 0.0)) / 0.4375 - 1.0) * amplitude;
float angleY = (fbm(vec3(seed, 0.0, t)) / 0.4375 - 1.0) * amplitude;
float angleZ = (fbm(vec3(0.0, seed, t)) / 0.4375 - 1.0) * amplitude;
float cx = cos(angleX); float sx = sin(angleX);
float cy = cos(angleY); float sy = sin(angleY);
float cz = cos(angleZ); float sz = sin(angleZ);
// Combined rotation matrix (Rz * Ry * Rx)
mat3 rot = mat3(
cy * cz, cx * sz + sx * sy * cz, sx * sz - cx * sy * cz,
-cy * sz, cx * cz - sx * sy * sz, sx * cz + cx * sy * sz,
sy, -sx * cy, cx * cy
);
// Per-instance translation offset from layered noise (Brownian-like)
vec3 offset = vec3(
(fbm(vec3(seed + 31.7, t, 0.0)) / 0.4375 - 1.0),
(fbm(vec3(seed + 31.7, 0.0, t)) / 0.4375 - 1.0),
(fbm(vec3(0.0, seed + 31.7, t)) / 0.4375 - 1.0)
) * amplitude;
// Bounding-sphere center transformed by the linear part only (no translation)
vec3 localCenter = mat3(transform) * uInvariantBoundingSphere.xyz;
// Rotate basis vectors
mat4 result = transform;
result[0].xyz = rot * transform[0].xyz;
result[1].xyz = rot * transform[1].xyz;
result[2].xyz = rot * transform[2].xyz;
// Adjust translation so rotation pivots around the transformed center
result[3].xyz = transform[3].xyz + localCenter - rot * localCenter + offset;
return result;
}
return transform;
}
`;

View File

@@ -121,34 +121,6 @@ vec3 perturbNormal(in vec3 position, in vec3 normal, in float height, in float s
return normalize(abs(det) * normal - scale * surfGrad);
}
float hash(in float h) {
return fract(sin(h) * 43758.5453123);
}
float noise(in vec3 x) {
vec3 p = floor(x);
vec3 f = fract(x);
f = f * f * (3.0 - 2.0 * f);
float n = p.x + p.y * 157.0 + 113.0 * p.z;
return mix(
mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),
mix(hash(n + 157.0), hash(n + 158.0), f.x), f.y),
mix(mix(hash(n + 113.0), hash(n + 114.0), f.x),
mix(hash(n + 270.0), hash(n + 271.0), f.x), f.y), f.z);
}
float fbm(in vec3 p) {
float f = 0.0;
f += 0.5 * noise(p);
p *= 2.01;
f += 0.25 * noise(p);
p *= 2.02;
f += 0.125 * noise(p);
return f;
}
#ifdef dXrayShaded
float calcXrayShadedAlpha(in float alpha, const in vec3 normal) {
#if defined(dXrayShaded_on)

View File

@@ -12,6 +12,8 @@ uniform vec4 uLod;
uniform bool uDoubleSided;
uniform int uPickType;
uniform float uTime;
uniform bool uEnableAnimation;
#if dClipObjectCount != 0
uniform int uClipObjectType[dClipObjectCount];

View File

@@ -279,4 +279,32 @@ mat3 adjoint(const in mat4 m) {
#define isNaN isnan
#define isInf isinf
#endif
float hash(in float h) {
return fract(sin(h) * 43758.5453123);
}
float noise(in vec3 x) {
vec3 p = floor(x);
vec3 f = fract(x);
f = f * f * (3.0 - 2.0 * f);
float n = p.x + p.y * 157.0 + 113.0 * p.z;
return mix(
mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),
mix(hash(n + 157.0), hash(n + 158.0), f.x), f.y),
mix(mix(hash(n + 113.0), hash(n + 114.0), f.x),
mix(hash(n + 270.0), hash(n + 271.0), f.x), f.y), f.z);
}
float fbm(in vec3 p) {
float f = 0.0;
f += 0.5 * noise(p);
p *= 2.01;
f += 0.25 * noise(p);
p *= 2.02;
f += 0.125 * noise(p);
return f;
}
`;

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2020-2025 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 Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -14,6 +14,7 @@ precision highp int;
#include color_vert_params
#include size_vert_params
#include common_clip
#include common_animation
uniform mat4 uModelView;
@@ -46,11 +47,14 @@ void main() {
#include assign_clipping_varying
#include assign_size
mat4 modelTransform = uModel * aTransform;
mat4 transform = applyTumble(aTransform, aInstance, float(uObjectId));
vec3 wigStart = applyWiggle(aStart, aGroup, aInstance);
vec3 wigEnd = applyWiggle(aEnd, aGroup, aInstance);
mat4 modelTransform = uModel * transform;
vTransform = modelTransform;
vStart = (modelTransform * vec4(aStart, 1.0)).xyz;
vEnd = (modelTransform * vec4(aEnd, 1.0)).xyz;
vStart = (modelTransform * vec4(wigStart, 1.0)).xyz;
vEnd = (modelTransform * vec4(wigEnd, 1.0)).xyz;
vSize = size * aScale * uModelScale;
vCap = aCap;

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 Alexander Rose <alexander.rose@weirdbyte.de>
*
@@ -16,6 +16,7 @@ precision highp int;
#include color_vert_params
#include size_vert_params
#include common_clip
#include common_animation
uniform float uPixelRatio;
uniform vec4 uViewport;
@@ -48,18 +49,20 @@ void main(){
#include assign_clipping_varying
#include assign_size
mat4 modelView = uView * uModel * aTransform;
mat4 transform = applyTumble(aTransform, aInstance, float(uObjectId));
vec3 wigStart = applyWiggle(aStart, group, aInstance);
vec3 wigEnd = applyWiggle(aEnd, group, aInstance);
mat4 modelView = uView * uModel * transform;
// camera space
vec4 start = modelView * vec4(aStart, 1.0);
vec4 end = modelView * vec4(aEnd, 1.0);
vec4 start = modelView * vec4(wigStart, 1.0);
vec4 end = modelView * vec4(wigEnd, 1.0);
// assign position
vec4 position4 = vec4((aMapping.y < 0.5) ? aStart : aEnd, 1.0);
vec4 mvPosition = modelView * position4;
vViewPosition = mvPosition.xyz;
vec4 position4 = vec4((aMapping.y < 0.5) ? wigStart : wigEnd, 1.0);
vViewPosition = (aMapping.y < 0.5) ? start.xyz : end.xyz;
vModelPosition = (uModel * aTransform * position4).xyz; // for clipping in frag shader
vModelPosition = (uModel * transform * position4).xyz; // for clipping in frag shader
// special case for perspective projection, and segments that terminate either in, or behind, the camera plane
// clearly the gpu firmware has a way of addressing this issue when projecting into ndc space

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 Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -14,6 +14,7 @@ precision highp sampler2D;
#include common_vert_params
#include color_vert_params
#include common_clip
#include common_animation
#include texture3d_from_2d_linear
#ifdef dGeometryType_textureMesh

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 Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -14,6 +14,7 @@ precision highp int;
#include color_vert_params
#include size_vert_params
#include common_clip
#include common_animation
uniform float uPixelRatio;
uniform vec4 uViewport;

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>
*/
@@ -14,6 +14,7 @@ precision highp int;
#include color_vert_params
#include size_vert_params
#include common_clip
#include common_animation
uniform mat4 uModelView;
uniform mat4 uInvProjection;
@@ -68,7 +69,7 @@ const mat4 D = mat4(
* "GPU-Based Ray-Casting of Quadratic Surfaces" http://dl.acm.org/citation.cfm?id=2386396
* by Christian Sigg, Tim Weyrich, Mario Botsch, Markus Gross.
*/
void quadraticProjection(const in vec3 position, const in float radius, const in vec2 mapping) {
void quadraticProjection(const in vec3 position, const in float radius, const in vec2 mapping, const in mat4 transform) {
vec2 xbc, ybc;
mat4 T = mat4(
@@ -78,7 +79,7 @@ void quadraticProjection(const in vec3 position, const in float radius, const in
position.x, position.y, position.z, 1.0
);
mat4 R = transpose4(uProjection * uModelView * aTransform * T);
mat4 R = transpose4(uProjection * uModelView * transform * T);
float A = dot(R[3], D * R[3]);
float B = -2.0 * dot(R[0], D * R[3]);
float C = dot(R[0], D * R[0]);
@@ -119,6 +120,9 @@ void main(void){
vec3 position = positionGroup.rgb;
float group = positionGroup.a;
position = applyWiggle(position, group, aInstance);
mat4 transform = applyTumble(aTransform, aInstance, float(uObjectId));
#include assign_color_varying
#include assign_marker_varying
#include assign_clipping_varying
@@ -127,7 +131,7 @@ void main(void){
vRadius = size * uModelScale;
vec4 position4 = vec4(position, 1.0);
vModelPosition = (uModel * aTransform * position4).xyz; // for clipping in frag shader
vModelPosition = (uModel * transform * position4).xyz; // for clipping in frag shader
float d;
if (uLod.w != 0.0 && (uLod.x != 0.0 || uLod.y != 0.0)) {
@@ -143,7 +147,7 @@ void main(void){
}
}
vec4 mvPosition = uModelView * aTransform * position4;
vec4 mvPosition = uModelView * transform * position4;
#ifdef dApproximate
vec4 mvCorner = vec4(mvPosition.xyz, 1.0);
@@ -156,7 +160,7 @@ void main(void){
gl_Position = uProjection * mvCorner;
} else if (uIsAsymmetricProjection) {
gl_Position = uProjection * vec4(mvPosition.xyz, 1.0);
quadraticProjection(position, vRadius / uModelScale, mapping);
quadraticProjection(position, vRadius / uModelScale, mapping, transform);
} else {
gl_Position = uProjection * vec4(mvPosition.xyz, 1.0);
sphereProjection(mvPosition.xyz, vRadius, mapping);

View File

@@ -104,7 +104,7 @@ export interface COMPAT_vertex_array_object {
bindVertexArray(arrayObject: WebGLVertexArrayObject | null): void;
createVertexArray(): WebGLVertexArrayObject | null;
deleteVertexArray(arrayObject: WebGLVertexArrayObject): void;
isVertexArray(value: any): value is WebGLVertexArrayObject;
isVertexArray(value: any): boolean
}
export function getVertexArrayObject(gl: GLRenderingContext): COMPAT_vertex_array_object | null {
@@ -484,7 +484,7 @@ export interface COMPAT_disjoint_timer_query {
/** Records the current time into the corresponding query object. */
queryCounter: (query: WebGLQuery, target: number) => void
/** Returns information about a query target. */
getQuery: (target: number, pname: number) => WebGLQuery | number
getQuery: (target: number, pname: number) => WebGLQuery | null
/** Return the state of a query object. */
getQueryParameter: (query: WebGLQuery, pname: number) => number | boolean
}

View File

@@ -23,10 +23,12 @@ export function uint8ToString(array: Uint8Array) {
if (array.length > ChunkSize) {
const c = [];
for (let i = 0; i < array.length; i += ChunkSize) {
// @ts-ignore
c.push(String.fromCharCode.apply(null, array.subarray(i, i + ChunkSize)));
}
return c.join('');
} else {
// @ts-ignore
return String.fromCharCode.apply(null, array);
}
}

View File

@@ -0,0 +1,385 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { parseObj } from '../obj/parser';
// Simple triangle
const objTriangle = `# simple triangle
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
`;
// Quad that gets fan-triangulated into 2 triangles
const objQuad = `# quad fan-triangulated
v -1.0 -1.0 0.0
v 1.0 -1.0 0.0
v 1.0 1.0 0.0
v -1.0 1.0 0.0
f 1 2 3 4
`;
// Vertex normals
const objWithNormals = `# vertex normals
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
f 1//1 2//2 3//3
`;
// v/vt/vn format (texture coords are ignored but should not break parsing)
const objWithTexture = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
f 1/1/1 2/2/1 3/3/1
`;
// Multiple materials / usemtl groups
const objMultiMaterial = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
v 2.0 0.0 0.0
v 2.5 1.0 0.0
usemtl red
f 1 2 3
usemtl green
f 2 4 5
`;
// Negative indices (relative addressing)
const objNegativeIndices = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f -3 -2 -1
`;
// Comments and blank lines should be ignored
const objWithComments = `# header comment
# another comment
v 0.0 0.0 0.0
# inline comment after data
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
`;
// Unsupported directives (g, o, s, mtllib, vt, vp) should be silently skipped
const objUnsupportedDirectives = `mtllib material.mtl
o MyObject
g mygroup
s 1
v 0.0 0.0 0.0
vt 0.0 0.0
vp 0.0 1.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
`;
// Cube (6 faces × 2 triangles = 12 triangles)
const objCube = `# unit cube
v -1 -1 -1
v 1 -1 -1
v 1 1 -1
v -1 1 -1
v -1 -1 1
v 1 -1 1
v 1 1 1
v -1 1 1
# bottom (-z)
f 1 2 3
f 1 3 4
# top (+z)
f 5 6 7
f 5 7 8
# front (+x)
f 2 6 7
f 2 7 3
# back (-x)
f 5 1 4
f 5 4 8
# left (-y)
f 1 5 6
f 1 6 2
# right (+y)
f 4 3 7
f 4 7 8
`;
// CRLF line endings
const objCRLF = '# crlf triangle\r\nv 0.0 0.0 0.0\r\nv 1.0 0.0 0.0\r\nv 0.5 1.0 0.0\r\nf 1 2 3\r\n';
// Tabs and leading whitespace before keywords
const objLeadingWhitespace = '\tv 0.0 0.0 0.0\n v 1.0 0.0 0.0\n\t v 0.5 1.0 0.0\n\tf 1 2 3\n';
// Degenerate face (fewer than 3 vertices) should be skipped with a warning
const objDegenerateFace = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2
f 1 2 3
`;
// Mixed face-vertices: some reference a normal, some do not, within one mesh
const objMixedNormals = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
f 1//1 2 3//1
`;
// Negative normal indices (relative addressing for normals)
const objNegativeNormalIndices = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
f 1//-1 2//-1 3//-1
`;
// usemtl reuse: a material name is referenced again after another material
const objReusedMaterial = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
v 2.0 0.0 0.0
v 2.5 1.0 0.0
usemtl red
f 1 2 3
usemtl green
f 2 4 5
usemtl red
f 1 3 4
`;
// Empty file
const objEmpty = '';
// File with vertices but no faces
const objNoFaces = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
`;
describe('obj reader', () => {
it('parses a simple triangle', async () => {
const parsed = await parseObj(objTriangle).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
// First vertex
expect(obj.positions[0]).toBeCloseTo(0.0);
expect(obj.positions[1]).toBeCloseTo(0.0);
expect(obj.positions[2]).toBeCloseTo(0.0);
// Triangle indices (0-based)
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('fan-triangulates a quad into two triangles', async () => {
const parsed = await parseObj(objQuad).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(4);
expect(obj.triangleCount).toBe(2);
// Fan from vertex 0: (0,1,2) and (0,2,3)
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2, 0, 2, 3]);
});
it('parses vertex normals with v//vn format', async () => {
const parsed = await parseObj(objWithNormals).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.normalCount).toBe(3);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.normalIndices)).toEqual([0, 1, 2]);
// Normal z component of first normal
expect(obj.normals[2]).toBeCloseTo(1.0);
});
it('parses v/vt/vn format (texture coords ignored)', async () => {
const parsed = await parseObj(objWithTexture).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.normalCount).toBe(1);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.normalIndices)).toEqual([0, 0, 0]);
});
it('assigns material groups via usemtl', async () => {
const parsed = await parseObj(objMultiMaterial).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.triangleCount).toBe(2);
// 'default' is always first; 'red' and 'green' added on use
expect(obj.groups[1]).toBe('red');
expect(obj.groups[2]).toBe('green');
// First triangle belongs to 'red' (index 1), second to 'green' (index 2)
expect(obj.groupIndices[0]).toBe(1);
expect(obj.groupIndices[1]).toBe(2);
});
it('handles negative (relative) vertex indices', async () => {
const parsed = await parseObj(objNegativeIndices).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.triangleCount).toBe(1);
// -3, -2, -1 with posCount=3 → 0, 1, 2
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('ignores comments and blank lines', async () => {
const parsed = await parseObj(objWithComments).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
});
it('silently skips unsupported directives', async () => {
const parsed = await parseObj(objUnsupportedDirectives).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
});
it('parses a cube (12 triangles, 8 vertices)', async () => {
const parsed = await parseObj(objCube).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(8);
expect(obj.triangleCount).toBe(12);
expect(obj.positionIndices.length).toBe(36); // 12 * 3
});
it('returns no normals when none are defined', async () => {
const parsed = await parseObj(objTriangle).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.normalCount).toBe(0);
// All normal indices should be -1
expect(Array.from(obj.normalIndices)).toEqual([-1, -1, -1]);
});
it('default group is always present', async () => {
const parsed = await parseObj(objTriangle).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.groups[0]).toBe('default');
expect(obj.groupIndices[0]).toBe(0);
});
it('parses CRLF line endings', async () => {
const parsed = await parseObj(objCRLF).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('handles tabs and leading whitespace before keywords', async () => {
const parsed = await parseObj(objLeadingWhitespace).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('skips a degenerate face and emits a warning', async () => {
const parsed = await parseObj(objDegenerateFace).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
// Only the valid triangle survives
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
// A warning was recorded for the degenerate face
expect(parsed.warnings.length).toBeGreaterThan(0);
expect(parsed.warnings.some(w => w.includes('degenerate'))).toBe(true);
});
it('parses faces with mixed normal/no-normal vertices', async () => {
const parsed = await parseObj(objMixedNormals).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.normalCount).toBe(1);
expect(obj.triangleCount).toBe(1);
// First and third vertices reference normal 0; the middle has none (-1)
expect(Array.from(obj.normalIndices)).toEqual([0, -1, 0]);
});
it('handles negative (relative) normal indices', async () => {
const parsed = await parseObj(objNegativeNormalIndices).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.normalCount).toBe(1);
expect(obj.triangleCount).toBe(1);
// -1 with normCount=1 → 0
expect(Array.from(obj.normalIndices)).toEqual([0, 0, 0]);
});
it('reuses an already-seen usemtl material name', async () => {
const parsed = await parseObj(objReusedMaterial).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.triangleCount).toBe(3);
// 'red' (1) and 'green' (2) are the only added groups; no duplicate 'red'
expect(obj.groups[1]).toBe('red');
expect(obj.groups[2]).toBe('green');
expect(obj.groups.indexOf('red')).toBe(1);
expect(obj.groups.lastIndexOf('red')).toBe(1);
// Third face reuses 'red' (index 1)
expect(Array.from(obj.groupIndices)).toEqual([1, 2, 1]);
});
it('parses an empty file', async () => {
const parsed = await parseObj(objEmpty).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(0);
expect(obj.normalCount).toBe(0);
expect(obj.triangleCount).toBe(0);
expect(obj.groups[0]).toBe('default');
});
it('parses a file with vertices but no faces', async () => {
const parsed = await parseObj(objNoFaces).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(0);
expect(obj.positionIndices.length).toBe(0);
});
});

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2020 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 Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -18,7 +18,7 @@ export async function readCcp4Header(file: FileHandle): Promise<{ header: Ccp4He
// 53 MAP Character string 'MAP ' to identify file type
const MAP = String.fromCharCode(
buffer.readUInt8(52 * 4), buffer.readUInt8(52 * 4 + 1),
buffer.readUInt8(52 * 4 + 2), buffer.readUInt8(52 * 4 + 3)
buffer.readUInt8(52 * 4 + 2), buffer.readUInt8(52 * 4 + 3) || 32 // null as space
);
if (MAP !== 'MAP ') {
throw new Error('ccp4 format error, missing "MAP " string');

View File

@@ -1,7 +1,7 @@
/**
* Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.411, IHM 1.28, MA 1.4.9.
* Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.414, IHM 1.28, MA 1.4.9.
*
* @author molstar/ciftools package
*/

View File

@@ -1,7 +1,7 @@
/**
* Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.411, IHM 1.28, MA 1.4.9.
* Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.414, IHM 1.28, MA 1.4.9.
*
* @author molstar/ciftools package
*/

View File

@@ -1,7 +1,7 @@
/**
* Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.411, IHM 1.28, MA 1.4.9.
* Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.414, IHM 1.28, MA 1.4.9.
*
* @author molstar/ciftools package
*/

View File

@@ -9,6 +9,7 @@ import { Dsn6File, Dsn6Header } from './schema';
import { ReaderResult as Result } from '../result';
import { FileHandle } from '../../common/file-handle';
import { SimpleBuffer } from '../../../mol-io/common/simple-buffer';
import { uint8ToString } from '../../common/binary';
export const dsn6HeaderSize = 512;
@@ -70,7 +71,7 @@ function getBlocks(header: Dsn6Header) {
export async function readDsn6Header(file: FileHandle): Promise<{ header: Dsn6Header, littleEndian: boolean }> {
const { buffer } = await file.readBuffer(0, dsn6HeaderSize);
const brixStr = String.fromCharCode.apply(null, buffer) as string;
const brixStr = uint8ToString(buffer);
const isBrix = brixStr.startsWith(':-)');
const littleEndian = isBrix || buffer.readInt16LE(18 * 2) === 100;
const header = isBrix ? parseBrixHeader(brixStr) : parseDsn6Header(buffer, littleEndian);

View File

@@ -1,22 +1,323 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ReaderResult as Result } from '../result';
import { Task, RuntimeContext } from '../../../mol-task';
import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
import { ChunkedArray } from '../../../mol-data/util';
import { ObjFile } from './schema';
import { StringLike } from '../../common/string-like';
import { Tokenizer } from '../common/text/tokenizer';
import { parseInt, parseFloat } from '../common/text/number-parser';
async function parseInternal(data: string, ctx: RuntimeContext): Promise<Result<Mesh>> {
// TODO
const mesh: Mesh = Mesh.createEmpty();
// Mesh.computeNormalsImmediate(mesh)
return Result.success(mesh);
// OBJ file format specification: http://www.martinreddy.net/gfx/3d/OBJ.spec
interface State {
tokenizer: Tokenizer
positions: ChunkedArray<number, 3>
normals: ChunkedArray<number, 3>
positionIndices: ChunkedArray<number, 3>
normalIndices: ChunkedArray<number, 3>
groupIndices: ChunkedArray<number, 1>
groups: string[]
currentGroupIdx: number
warnings: string[]
}
export function parse(data: string) {
return Task.create<Result<Mesh>>('Parse OBJ', async ctx => {
function State(data: StringLike): State {
return {
tokenizer: Tokenizer(data),
positions: ChunkedArray.create(Float32Array, 3, 1024),
normals: ChunkedArray.create(Float32Array, 3, 1024),
positionIndices: ChunkedArray.create(Int32Array, 3, 1024),
normalIndices: ChunkedArray.create(Int32Array, 3, 1024),
groupIndices: ChunkedArray.create(Uint32Array, 1, 1024),
groups: ['default'],
currentGroupIdx: 0,
warnings: []
};
}
// Character codes used for keyword identification without materializing strings
const CC_v = 118; // 'v'
const CC_n = 110; // 'n'
const CC_f = 102; // 'f'
const CC_u = 117; // 'u'
const CC_s = 115; // 's'
const CC_e = 101; // 'e'
const CC_m = 109; // 'm'
const CC_t = 116; // 't'
const CC_l = 108; // 'l'
const CC_HASH = 35; // '#'
const CC_NEWLINE = 10; // '\n'
const CC_CR = 13; // '\r'
const CC_SPACE = 32; // ' '
const CC_TAB = 9; // '\t'
const CC_SLASH = 47; // '/'
/** Skip to the end of the current line without returning it. */
function skipLine(tokenizer: Tokenizer): void {
const { data } = tokenizer;
while (tokenizer.position < tokenizer.length) {
const c = data.charCodeAt(tokenizer.position);
if (c === CC_NEWLINE) { ++tokenizer.position; ++tokenizer.lineNumber; return; }
if (c === CC_CR) {
++tokenizer.position;
++tokenizer.lineNumber;
if (tokenizer.position < tokenizer.length && data.charCodeAt(tokenizer.position) === CC_NEWLINE) ++tokenizer.position;
return;
}
++tokenizer.position;
}
}
/** Skip inline whitespace (space/tab only — does not cross newlines). */
function skipInlineWS(tokenizer: Tokenizer): void {
const { data } = tokenizer;
while (tokenizer.position < tokenizer.length) {
const c = data.charCodeAt(tokenizer.position);
if (c !== CC_SPACE && c !== CC_TAB) return;
++tokenizer.position;
}
}
/**
* Read one whitespace-delimited token on the current line.
* Returns false when end-of-line / end-of-file is reached before any character.
* Leaves tokenizer.tokenStart/tokenEnd set to the token boundaries.
*/
function readInlineToken(tokenizer: Tokenizer): boolean {
skipInlineWS(tokenizer);
const { data } = tokenizer;
if (tokenizer.position >= tokenizer.length) return false;
const c = data.charCodeAt(tokenizer.position);
if (c === CC_NEWLINE || c === CC_CR || c === CC_HASH) return false;
tokenizer.tokenStart = tokenizer.position;
while (tokenizer.position < tokenizer.length) {
const ch = data.charCodeAt(tokenizer.position);
if (ch === CC_SPACE || ch === CC_TAB || ch === CC_NEWLINE || ch === CC_CR || ch === CC_HASH) break;
++tokenizer.position;
}
tokenizer.tokenEnd = tokenizer.position;
return tokenizer.tokenEnd > tokenizer.tokenStart;
}
/**
* Read up to `maxCount` face-vertex tokens from the current line into `facePos` / `faceNorm`.
* Returns the number of tokens read.
* Face vertex format: posIdx[/[texIdx][/normIdx]] (all 1-based, may be negative).
*/
function readFaceTokens(
tokenizer: Tokenizer,
facePos: Int32Array, faceNorm: Int32Array,
maxCount: number,
posCount: number, normCount: number
): number {
const { data } = tokenizer;
let count = 0;
while (count < maxCount && readInlineToken(tokenizer)) {
const start = tokenizer.tokenStart;
const end = tokenizer.tokenEnd;
// Find first slash within [start, end)
let slash1 = -1;
for (let i = start; i < end; ++i) {
if (data.charCodeAt(i) === CC_SLASH) { slash1 = i; break; }
}
let posIdx: number;
let normIdx = -1;
if (slash1 === -1) {
// "v"
const p = parseInt(data, start, end);
posIdx = p < 0 ? posCount + p : p - 1;
} else {
const p = parseInt(data, start, slash1);
posIdx = p < 0 ? posCount + p : p - 1;
// Find second slash
let slash2 = -1;
for (let i = slash1 + 1; i < end; ++i) {
if (data.charCodeAt(i) === CC_SLASH) { slash2 = i; break; }
}
if (slash2 !== -1 && slash2 + 1 < end) {
// "v/vt/vn" or "v//vn"
const n = parseInt(data, slash2 + 1, end);
normIdx = n < 0 ? normCount + n : n - 1;
}
// else "v/vt" — no normal
}
facePos[count] = posIdx;
faceNorm[count] = normIdx;
++count;
}
return count;
}
// Reusable scratch buffers for face vertex data (polygons up to MAX_FACE_VERTICES vertices)
const MAX_FACE_VERTICES = 256;
const _facePos = new Int32Array(MAX_FACE_VERTICES);
const _faceNorm = new Int32Array(MAX_FACE_VERTICES);
function handleVertex(state: State): void {
const { tokenizer } = state;
let x = 0, y = 0, z = 0;
if (readInlineToken(tokenizer)) x = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) y = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) z = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
ChunkedArray.add3(state.positions, x, y, z);
skipLine(tokenizer);
}
function handleNormal(state: State): void {
const { tokenizer } = state;
let x = 0, y = 0, z = 0;
if (readInlineToken(tokenizer)) x = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) y = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) z = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
ChunkedArray.add3(state.normals, x, y, z);
skipLine(tokenizer);
}
function handleFace(state: State): void {
const { tokenizer } = state;
const posCount = state.positions.elementCount;
const normCount = state.normals.elementCount;
const n = readFaceTokens(tokenizer, _facePos, _faceNorm, MAX_FACE_VERTICES, posCount, normCount);
if (n < 3) {
state.warnings.push(`Line ${tokenizer.lineNumber}: degenerate face with ${n} vertices, skipped`);
skipLine(tokenizer);
return;
}
// Warn if the polygon exceeded the scratch buffer capacity and was truncated.
if (n === MAX_FACE_VERTICES && readInlineToken(tokenizer)) {
state.warnings.push(`Line ${tokenizer.lineNumber}: face with more than ${MAX_FACE_VERTICES} vertices truncated`);
}
// Fan-triangulate: (0,1,2), (0,2,3), ...
const p0 = _facePos[0], n0 = _faceNorm[0];
for (let i = 1; i < n - 1; ++i) {
ChunkedArray.add3(state.positionIndices, p0, _facePos[i], _facePos[i + 1]);
ChunkedArray.add3(state.normalIndices, n0, _faceNorm[i], _faceNorm[i + 1]);
ChunkedArray.add(state.groupIndices, state.currentGroupIdx);
}
skipLine(tokenizer);
}
function handleUsemtl(state: State): void {
const { tokenizer } = state;
// Read the rest of the line as the material name (trimmed)
skipInlineWS(tokenizer);
tokenizer.tokenStart = tokenizer.position;
const { data } = tokenizer;
while (tokenizer.position < tokenizer.length) {
const c = data.charCodeAt(tokenizer.position);
if (c === CC_NEWLINE || c === CC_CR) break;
++tokenizer.position;
}
// Trim trailing whitespace
let end = tokenizer.position;
while (end > tokenizer.tokenStart && (data.charCodeAt(end - 1) === CC_SPACE || data.charCodeAt(end - 1) === CC_TAB)) --end;
const name = end > tokenizer.tokenStart ? data.substring(tokenizer.tokenStart, end) : 'default';
const existing = state.groups.indexOf(name);
if (existing !== -1) {
state.currentGroupIdx = existing;
} else {
state.currentGroupIdx = state.groups.length;
state.groups.push(name);
}
skipLine(tokenizer);
}
async function parseInternal(data: StringLike, ctx: RuntimeContext): Promise<Result<ObjFile>> {
const state = State(data);
const { tokenizer } = state;
const updateChunk = 100000;
while (tokenizer.position < tokenizer.length) {
// Skip full-line whitespace and newlines between lines
const c0 = tokenizer.data.charCodeAt(tokenizer.position);
if (c0 === CC_NEWLINE) { ++tokenizer.position; ++tokenizer.lineNumber; continue; }
if (c0 === CC_CR) {
++tokenizer.position; ++tokenizer.lineNumber;
if (tokenizer.position < tokenizer.length && tokenizer.data.charCodeAt(tokenizer.position) === CC_NEWLINE) ++tokenizer.position;
continue;
}
if (c0 === CC_SPACE || c0 === CC_TAB) { skipInlineWS(tokenizer); continue; }
if (c0 === CC_HASH) { skipLine(tokenizer); continue; }
// Identify keyword by inspecting character codes — no string allocation
const c1 = tokenizer.position + 1 < tokenizer.length ? tokenizer.data.charCodeAt(tokenizer.position + 1) : -1;
if (c0 === CC_f && (c1 === CC_SPACE || c1 === CC_TAB)) {
// "f " — face
tokenizer.position += 2;
handleFace(state);
} else if (c0 === CC_v) {
if (c1 === CC_SPACE || c1 === CC_TAB) {
// "v " — vertex position
tokenizer.position += 2;
handleVertex(state);
} else if (c1 === CC_n) {
// "vn" — vertex normal
tokenizer.position += 2;
handleNormal(state);
} else {
// "vt", "vp", etc — skip
skipLine(tokenizer);
}
} else if (
c0 === CC_u &&
tokenizer.position + 6 < tokenizer.length &&
tokenizer.data.charCodeAt(tokenizer.position + 1) === CC_s &&
tokenizer.data.charCodeAt(tokenizer.position + 2) === CC_e &&
tokenizer.data.charCodeAt(tokenizer.position + 3) === CC_m &&
tokenizer.data.charCodeAt(tokenizer.position + 4) === CC_t &&
tokenizer.data.charCodeAt(tokenizer.position + 5) === CC_l
) {
// "usemtl"
tokenizer.position += 6;
handleUsemtl(state);
} else {
// "o", "g", "s", "mtllib", "call", etc. — skip entire line
skipLine(tokenizer);
}
if (ctx.shouldUpdate && tokenizer.lineNumber % updateChunk === 0) {
await ctx.update({ message: 'Parsing OBJ', current: tokenizer.position, max: tokenizer.length });
}
}
const posArr = ChunkedArray.compact(state.positions) as Float32Array;
const normArr = ChunkedArray.compact(state.normals) as Float32Array;
const posIdxArr = ChunkedArray.compact(state.positionIndices) as Int32Array;
const normIdxArr = ChunkedArray.compact(state.normalIndices) as Int32Array;
const grpIdxArr = ChunkedArray.compact(state.groupIndices) as Uint32Array;
const result: ObjFile = {
positions: posArr,
normals: normArr,
positionIndices: posIdxArr,
normalIndices: normIdxArr,
groupIndices: grpIdxArr,
groups: state.groups,
positionCount: state.positions.elementCount,
normalCount: state.normals.elementCount,
triangleCount: posIdxArr.length / 3
};
return Result.success(result, state.warnings);
}
export function parseObj(data: StringLike) {
return Task.create<Result<ObjFile>>('Parse OBJ', async ctx => {
return await parseInternal(data, ctx);
});
}

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
/**
* Intermediate representation of a parsed OBJ file.
*
* Positions and normals are stored as raw arrays from the file.
* Faces have been triangulated (fan-triangulation) and are stored as
* separate flat arrays of per-triangle-vertex position and normal indices.
* Normal indices of -1 indicate that the vertex has no explicit normal.
*/
export interface ObjFile {
/** Raw position data from `v` lines, interleaved [x0,y0,z0, x1,y1,z1, ...] */
readonly positions: Float32Array
/** Raw normal data from `vn` lines, interleaved [nx0,ny0,nz0, ...]. Length 0 if no normals. */
readonly normals: Float32Array
/**
* Per-face-vertex position index (0-based), length = triangleCount * 3.
* Three consecutive values define one triangle: [p0, p1, p2, p3, p4, p5, ...]
*/
readonly positionIndices: Int32Array
/**
* Per-face-vertex normal index (0-based), length = triangleCount * 3.
* -1 means no explicit normal for that face-vertex.
*/
readonly normalIndices: Int32Array
/**
* Material/group index per triangle, length = triangleCount.
* Index into the `groups` array.
*/
readonly groupIndices: Uint32Array
/** Ordered list of material group names, populated from `usemtl` directives. */
readonly groups: ReadonlyArray<string>
readonly positionCount: number
readonly normalCount: number
readonly triangleCount: number
}

View File

@@ -61,7 +61,7 @@ export class MolEncoder extends LigandEncoder {
// no data for metal ions
if (!bondMap?.map) return;
bondMap.map.get(label_atom_id1)!.forEach((bond, label_atom_id2) => {
bondMap.map.get(label_atom_id1)!.map.forEach((bond, label_atom_id2) => {
const atom2 = atoms.get(label_atom_id2);
if (!atom2) return;

View File

@@ -52,7 +52,7 @@ export class Mol2Encoder extends LigandEncoder {
}
if (bondMap?.map) {
bondMap.map.get(label_atom_id1)!.forEach((bond, label_atom_id2) => {
bondMap.map.get(label_atom_id1)!.map.forEach((bond, label_atom_id2) => {
const atom2 = atoms.get(label_atom_id2);
if (!atom2) return;
@@ -132,7 +132,7 @@ export class Mol2Encoder extends LigandEncoder {
if (type_symbol1 === 'P') return 'P.3'; // 1.4, 4mpo/ligand?encoding=mol2&auth_seq_id=203 (PO4)
if (type_symbol1 === 'Co' || type_symbol1 === 'Ru') return type_symbol1 + '.oh'; // 1.5
const bonds = bondMap.map.get(label_atom_id1)!;
const bonds = bondMap.map.get(label_atom_id1)!.map;
const numBonds = bonds.size;
if (type_symbol1 === 'Ti' || type_symbol1 === 'Cr') { // 1.10
@@ -192,7 +192,7 @@ export class Mol2Encoder extends LigandEncoder {
let result = iter.next();
while (!result.done) {
const label_atom_id = result.value;
const adjacentBonds = bondMap.map.get(label_atom_id)!;
const adjacentBonds = bondMap.map.get(label_atom_id)!.map;
if (this.count(adjacentBonds, this, (_k, v) => v.order > 1 || BondType.is(BondType.Flag.Aromatic, v.flags))) {
// TODO check accurately for 2nd criterion with coordinates
return true;
@@ -207,7 +207,7 @@ export class Mol2Encoder extends LigandEncoder {
private isOC(nonmets: BondMap, bondMap: ComponentBond.Entry): boolean {
const nonmet = nonmets.entries().next()!.value as [string, { order: number, flags: number }];
if (!nonmet[0].startsWith('C')) return false;
const carbonBonds = bondMap.map.get(nonmet[0])!;
const carbonBonds = bondMap.map.get(nonmet[0])!.map;
if (carbonBonds.size !== 3) return false;
let count = 0;
@@ -216,7 +216,7 @@ export class Mol2Encoder extends LigandEncoder {
while (!result.done) {
const label_atom_id = result.value;
if (label_atom_id.startsWith('O')) {
const adjacentBonds = bondMap.map.get(label_atom_id)!;
const adjacentBonds = bondMap.map.get(label_atom_id)!.map;
if (this.count(adjacentBonds, this, (k, _v, ctx) => ctx.isNonMetalBond(k)) === 1) count++;
}
result = iter.next();
@@ -229,7 +229,7 @@ export class Mol2Encoder extends LigandEncoder {
private isOP(nonmets: BondMap, bondMap: ComponentBond.Entry): boolean {
const nonmet = nonmets.entries().next()!.value as [string, { order: number, flags: number }];
if (!nonmet[0].startsWith('P')) return false;
const phosphorusBonds = bondMap.map.get(nonmet[0])!;
const phosphorusBonds = bondMap.map.get(nonmet[0])!.map;
if (phosphorusBonds.size < 2) return false;
let count = 0;
@@ -238,7 +238,7 @@ export class Mol2Encoder extends LigandEncoder {
while (!result.done) {
const label_atom_id = result.value;
if (label_atom_id.startsWith('O')) {
const adjacentBonds = bondMap.map.get(label_atom_id)!;
const adjacentBonds = bondMap.map.get(label_atom_id)!.map;
if (this.count(adjacentBonds, this, (k, _v, ctx) => ctx.isNonMetalBond(k)) === 1) count++;
}
result = iter.next();
@@ -255,7 +255,7 @@ export class Mol2Encoder extends LigandEncoder {
const label_atom_id = result1.value;
if (!label_atom_id.startsWith('N')) return false;
const adjacentBonds = bondMap.map.get(label_atom_id)!;
const adjacentBonds = bondMap.map.get(label_atom_id)!.map;
if (adjacentBonds.size < 2) return false;
const iter2 = adjacentBonds.keys();
@@ -277,7 +277,7 @@ export class Mol2Encoder extends LigandEncoder {
while (!result.done) {
const label_atom_id = result.value;
if (label_atom_id.startsWith('O')) {
const adjacentBonds = bondMap.map.get(label_atom_id)!;
const adjacentBonds = bondMap.map.get(label_atom_id)!.map;
if (this.count(adjacentBonds, this, (k, _v, ctx) => ctx.isNonMetalBond(k))) count++;
}
result = iter.next();
@@ -292,7 +292,7 @@ export class Mol2Encoder extends LigandEncoder {
while (!result.done) {
const label_atom_id = result.value;
if (label_atom_id.startsWith('C')) {
const adjacentBonds = bondMap.map.get(label_atom_id)!;
const adjacentBonds = bondMap.map.get(label_atom_id)!.map;
if (this.count(adjacentBonds, this, (k, v) => k.startsWith('O') || k.startsWith('S') && v.order === 2)) return true;
}
result = iter.next();

View File

@@ -0,0 +1,194 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { RuntimeContext, Task } from '../../mol-task';
import { ShapeProvider } from '../../mol-model/shape/provider';
import { Color } from '../../mol-util/color';
import { ObjFile } from '../../mol-io/reader/obj/schema';
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
import { Shape } from '../../mol-model/shape';
import { ChunkedArray } from '../../mol-data/util';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { ColorNames } from '../../mol-util/color/names';
import { deepClone } from '../../mol-util/object';
import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
import { distinctColors } from '../../mol-util/color/distinct';
import { ValueCell } from '../../mol-util/value-cell';
export type ObjData = {
source: ObjFile,
transforms?: Mat4[],
}
export const ObjShapeParams = {
...Mesh.Params,
coloring: PD.MappedStatic('group', {
group: PD.Group({}),
uniform: PD.Group({
color: PD.Color(ColorNames.grey),
}, { isFlat: true })
}),
};
export type ObjShapeParams = typeof ObjShapeParams
/**
* Build an expanded mesh from indexed OBJ data.
*
* OBJ stores positions and normals as indexed arrays (each face-vertex
* references a position index and an optional normal index). Mesh.create
* requires a flat vertex array, so we expand the indexed data.
*
* A vertex key encodes (posIdx, normIdx, groupIdx) so that a position
* shared between faces of different material groups or with different normals
* gets a distinct mesh vertex — ensuring each vertex has one unambiguous
* group ID and normal.
*/
async function getMesh(ctx: RuntimeContext, obj: ObjFile, mesh?: Mesh): Promise<Mesh> {
const { positions, normals, positionIndices, normalIndices, groupIndices, triangleCount } = obj;
const hasNormals = obj.normalCount > 0;
const builderState = MeshBuilder.createState(triangleCount * 3, triangleCount, mesh);
const { vertices, normals: normBuf, indices, groups } = builderState;
// Group count for the composite dedup key; groups always contains at least 'default'.
const groupCount = Math.max(1, obj.groups.length);
// Map from position index to a map of (normal index, group) composite keys to expanded vertex index.
// Avoids allocating a string key per face-vertex on the hot path.
const vertexMap = new Map<number, Map<number, number>>();
const getVertex = (pi: number, ni: number, groupId: number): number => {
let inner = vertexMap.get(pi);
if (inner === undefined) {
inner = new Map<number, number>();
vertexMap.set(pi, inner);
}
const subKey = (ni + 1) * groupCount + groupId;
let idx = inner.get(subKey);
if (idx === undefined) {
idx = vertices.elementCount;
inner.set(subKey, idx);
const po = pi * 3;
ChunkedArray.add3(vertices, positions[po], positions[po + 1], positions[po + 2]);
if (hasNormals && ni >= 0) {
const no = ni * 3;
ChunkedArray.add3(normBuf, normals[no], normals[no + 1], normals[no + 2]);
} else {
ChunkedArray.add3(normBuf, 0, 0, 0);
}
ChunkedArray.add(groups, groupId);
}
return idx;
};
const updateChunk = 50000;
for (let t = 0; t < triangleCount; ++t) {
const triOffset = t * 3;
const groupId = groupIndices[t];
const i0 = getVertex(positionIndices[triOffset], hasNormals ? normalIndices[triOffset] : -1, groupId);
const i1 = getVertex(positionIndices[triOffset + 1], hasNormals ? normalIndices[triOffset + 1] : -1, groupId);
const i2 = getVertex(positionIndices[triOffset + 2], hasNormals ? normalIndices[triOffset + 2] : -1, groupId);
ChunkedArray.add3(indices, i0, i1, i2);
if (t % updateChunk === 0 && ctx.shouldUpdate) {
await ctx.update({ message: 'Building OBJ mesh', current: t, max: triangleCount });
}
}
const m = MeshBuilder.getMesh(builderState);
if (!hasNormals) Mesh.computeNormals(m);
ValueCell.updateIfChanged(m.varyingGroup, true);
return m;
}
type GroupColors = { kind: 'group', colors: Color[] } | { kind: 'uniform', color: Color }
function getColoring(obj: ObjFile, props: PD.Values<ObjShapeParams>): GroupColors {
const { coloring } = props;
if (coloring.name === 'uniform') {
return { kind: 'uniform', color: coloring.params.color };
}
// Generate distinct colors for each group
const n = Math.max(1, obj.groups.length);
const colors = distinctColors(n);
return { kind: 'group', colors };
}
function createShape(objData: ObjData, mesh: Mesh, groupColors: GroupColors) {
const { source, transforms } = objData;
return Shape.create(
'obj-mesh', source, mesh,
(groupId: number) => {
if (groupColors.kind === 'uniform') return groupColors.color;
const idx = Math.min(groupId, groupColors.colors.length - 1);
return groupColors.colors[idx];
},
() => 1,
(groupId: number) => {
const name = source.groups[groupId] ?? `Group ${groupId}`;
return name;
},
transforms
);
}
function makeShapeGetter() {
let _objData: ObjData | undefined;
let _props: PD.Values<ObjShapeParams> | undefined;
let _shape: Shape<Mesh>;
let _mesh: Mesh;
let _groupColors: GroupColors;
const getShape = async (ctx: RuntimeContext, objData: ObjData, props: PD.Values<ObjShapeParams>, shape?: Shape<Mesh>) => {
let newMesh = false;
let newColor = false;
if (!_objData || _objData !== objData) {
newMesh = true;
}
if (!_props || !PD.isParamEqual(ObjShapeParams.coloring, _props.coloring, props.coloring)) {
newColor = true;
}
if (newMesh) {
_mesh = await getMesh(ctx, objData.source, shape && shape.geometry);
_groupColors = getColoring(objData.source, props);
_shape = createShape(objData, _mesh, _groupColors);
} else if (newColor) {
_groupColors = getColoring(objData.source, props);
_shape = createShape(objData, _mesh, _groupColors);
}
_objData = objData;
_props = deepClone(props);
return _shape;
};
return getShape;
}
export function shapeFromObj(source: ObjFile, params?: { transforms?: Mat4[] }) {
return Task.create<ShapeProvider<ObjData, Mesh, ObjShapeParams>>('Shape Provider', async _ctx => {
return {
label: 'Mesh',
data: { source, transforms: params?.transforms },
params: ObjShapeParams,
getShape: makeShapeGetter(),
geometryUtils: Mesh.Utils
};
});
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2017-2022 Mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-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>
@@ -76,7 +76,7 @@ export namespace ComponentBond {
const aromatic = pdbx_aromatic_flag.value(i) === 'y';
const key = pdbx_ordinal.value(i);
if (entry.id !== id) {
if (entry.compId !== id) {
entry = addEntry(id);
}
@@ -96,25 +96,48 @@ export namespace ComponentBond {
return entries;
}
function getNormalized<T>(map: Map<string, T>, compId: string, atomId: string, isHydrogen: boolean) {
// handle deuterium -> hydrogen mapping for CCD bonds
// in DOD deuterium bonds are explicitly defined
if (isHydrogen && atomId.startsWith('D') && compId !== 'DOD') {
atomId = 'H' + atomId.substring(1);
}
return map.get(atomId);
}
export class Pairs {
readonly map: Map<string, { order: number, flags: number, key: number }> = new Map();
get(otherAtomId: string, isHydrogen: boolean) {
return getNormalized(this.map, this.compId, otherAtomId, isHydrogen);
}
constructor(readonly atomId: string, readonly compId: string) { }
}
export class Entry {
readonly map: Map<string, Map<string, { order: number, flags: number, key: number }>> = new Map();
readonly map: Map<string, Pairs> = new Map();
add(a: string, b: string, order: number, flags: number, key: number, swap = true) {
const e = this.map.get(a);
if (e !== void 0) {
const f = e.get(b);
const f = e.map.get(b);
if (f === void 0) {
e.set(b, { order, flags, key });
e.map.set(b, { order, flags, key });
}
} else {
const map = new Map<string, { order: number, flags: number, key: number }>();
map.set(b, { order, flags, key });
this.map.set(a, map);
const pairs = new Pairs(a, this.compId);
pairs.map.set(b, { order, flags, key });
this.map.set(a, pairs);
}
if (swap) this.add(b, a, order, flags, key, false);
}
constructor(public readonly id: string) { }
get(a: string, isHydrogen: boolean): Pairs | undefined {
return getNormalized(this.map, this.compId, a, isHydrogen);
}
constructor(public readonly compId: string) { }
}
}
}

View File

@@ -29,6 +29,14 @@ export function getCcp4Origin(header: Ccp4Header): Vec3 {
}
}
export function getCcp4Size(header: Ccp4Header): Vec3 {
if (header.xLength === 0.0 && header.yLength === 0.0 && header.zLength === 0.0) {
return Vec3.create(header.NX, header.NY, header.NZ);
} else {
return Vec3.create(header.xLength, header.yLength, header.zLength);
}
}
function getTypedArrayCtor(header: Ccp4Header) {
const valueType = getCcp4ValueType(header);
switch (valueType) {
@@ -43,7 +51,7 @@ function getTypedArrayCtor(header: Ccp4Header) {
export function volumeFromCcp4(source: Ccp4File, params?: { voxelSize?: Vec3, offset?: Vec3, label?: string, entryId?: string }): Task<Volume> {
return Task.create<Volume>('Create Volume', async ctx => {
const { header, values } = source;
const size = Vec3.create(header.xLength, header.yLength, header.zLength);
const size = getCcp4Size(header);
if (params && params.voxelSize) Vec3.mul(size, size, params.voxelSize);
const angles = Vec3.create(degToRad(header.alpha), degToRad(header.beta), degToRad(header.gamma));
const spacegroup = header.ISPG > 65536 ? 0 : header.ISPG;

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

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2018 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>
*/
export type AminoAlphabet =
@@ -82,4 +83,9 @@ export function getRnaOneLetterCode(residueName: string): NuclecicAlphabet {
export function getDnaOneLetterCode(residueName: string): NuclecicAlphabet {
const code = DnaOneLetterCodes[residueName];
return code || 'X';
}
}
export function getNucleicOneLetterCode(residueName: string): NuclecicAlphabet {
const code = RnaOneLetterCodes[residueName] || DnaOneLetterCodes[residueName];
return code || 'X';
}

View File

@@ -1,5 +1,5 @@
/**
* 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.
*
* Code-generated ion names params file. Names extracted from CCD components.
*

File diff suppressed because one or more lines are too long

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) 2017-2025 Mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-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>
@@ -155,7 +155,7 @@ function findBonds(unit: Unit.Atomic, props: BondComputationProps): IntraUnitBon
const key: number[] = [];
let lastResidue = -1;
let componentMap: Map<string, Map<string, { flags: number, order: number, key: number }>> | undefined = void 0;
let componentEntry: ComponentBond.Entry | undefined = void 0;
let isWatery = true, isDictionaryBased = true, isSequenced = true;
@@ -202,22 +202,22 @@ function findBonds(unit: Unit.Atomic, props: BondComputationProps): IntraUnitBon
const entitySeq = byEntityKey[index.getEntityFromChain(chainIndex[aI])];
if (entitySeq && entitySeq.sequence.microHet.has(seqIdA)) {
// compute for sequence positions with micro-heterogeneity
componentMap = void 0;
componentEntry = void 0;
} else {
componentMap = component.entries.get(compId)!.map;
componentEntry = component.entries.get(compId)!;
}
} else {
componentMap = void 0;
componentEntry = void 0;
}
}
lastResidue = raI;
const aeI = getElementIdx(elemA);
const isHa = isHydrogen(aeI);
const atomIdA = label_atom_id.value(aI);
const componentPairs = componentMap ? componentMap.get(atomIdA) : void 0;
const componentPairs = componentEntry ? componentEntry.get(atomIdA, isHa) : void 0;
const { indices, count, squaredDistances } = query3d.find(x[aI], y[aI], z[aI], maxRadius);
const isHa = isHydrogen(aeI);
const thresholdA = getElementThreshold(aeI);
const altA = label_alt_id.value(aI);
const metalA = MetalsSet.has(aeI);
@@ -248,7 +248,7 @@ function findBonds(unit: Unit.Atomic, props: BondComputationProps): IntraUnitBon
const rbI = residueIndex[bI];
// handle "component dictionary" bonds.
if (raI === rbI && componentPairs) {
const e = componentPairs.get(label_atom_id.value(bI)!);
const e = componentPairs.get(label_atom_id.value(bI), isHb);
if (e) {
atomA[atomA.length] = _aI;
atomB[atomB.length] = _bI;

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

@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { PluginStateAnimation } from '../model';
export const AnimateTime = PluginStateAnimation.create({
name: 'built-in.animate-time',
display: { name: 'Animate Time', description: 'Animate the passage of time in the 3D scene' },
isExportable: true,
params: () => ({
durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
}),
initialState: () => ({ }),
getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
async apply(animState, t, ctx) {
return t.current < ctx.params.durationInMs
? { kind: 'next', state: animState }
: { 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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2020 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>
@@ -23,7 +23,31 @@ export const PlyProvider = DataFormatProvider({
.to(data)
.apply(StateTransforms.Data.ParsePly, {}, { state: { isGhost: true } });
const shape = format.apply(StateTransforms.Model.ShapeFromPly);
const shape = format.apply(StateTransforms.Shape.ShapeFromPly);
await format.commit();
return { format: format.selector, shape: shape.selector };
},
visuals(plugin: PluginContext, data: { shape: StateObjectRef<PluginStateObject.Shape.Provider> }) {
const repr = plugin.state.data.build()
.to(data.shape)
.apply(StateTransforms.Representation.ShapeRepresentation3D);
return repr.commit();
}
});
export const ObjProvider = DataFormatProvider({
label: 'OBJ',
description: 'OBJ',
category: ShapeFormatCategory,
stringExtensions: ['obj'],
parse: async (plugin, data) => {
const format = plugin.state.data.build()
.to(data)
.apply(StateTransforms.Data.ParseObj, {}, { state: { isGhost: true } });
const shape = format.apply(StateTransforms.Shape.ShapeFromObj);
await format.commit();
@@ -39,6 +63,7 @@ export const PlyProvider = DataFormatProvider({
export const BuiltInShapeFormats = [
['ply', PlyProvider] as const,
['obj', ObjProvider] as const,
] as const;
export type BuildInShapeFormat = (typeof BuiltInShapeFormats)[number][0]

View File

@@ -0,0 +1,168 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Structure, StructureElement, Unit } from '../../mol-model/structure';
import { PluginStateObject } from '../objects';
import { StateTransforms } from '../transforms';
import { PluginContext } from '../../mol-plugin/context';
import { StateBuilder, StateObjectCell, StateSelection, StateTransform } from '../../mol-state';
import { StructureComponentRef } from '../manager/structure/hierarchy-state';
import { EmptyLoci, isEmptyLoci, Loci } from '../../mol-model/loci';
import { Wiggle } from '../../mol-theme/wiggle';
import { OrderedSet } from '../../mol-data/int';
type WiggleEachReprCallback = (update: StateBuilder.Root, repr: StateObjectCell<PluginStateObject.Molecule.Structure.Representation3D, StateTransform<typeof StateTransforms.Representation.StructureRepresentation3D>>, wiggle?: StateObjectCell<any, StateTransform<typeof StateTransforms.Representation.WiggleStructureRepresentation3DFromBundle>>) => Promise<void>
const WiggleManagerTag = 'wiggle-controls';
export async function setStructureWiggle(plugin: PluginContext, components: StructureComponentRef[], value: number, lociGetter: (structure: Structure) => Promise<StructureElement.Loci | EmptyLoci>, types?: string[]) {
await eachRepr(plugin, components, async (update, repr, wiggleCell) => {
if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return;
const structure = repr.obj!.data.sourceData;
// always use the root structure to get the loci so the wiggle
// stays applicable as long as the root structure does not change
const loci = await lociGetter(structure.root);
if (Loci.isEmpty(loci) || isEmptyLoci(loci)) return;
const layer = {
bundle: StructureElement.Bundle.fromLoci(loci),
value,
};
if (wiggleCell) {
const bundleLayers = [...wiggleCell.params!.values.layers, layer];
const filtered = getFilteredBundle(bundleLayers, structure);
update.to(wiggleCell).update(Wiggle.toBundle(filtered));
} else {
const filtered = getFilteredBundle([layer], structure);
update.to(repr.transform.ref)
.apply(StateTransforms.Representation.WiggleStructureRepresentation3DFromBundle, Wiggle.toBundle(filtered), { tags: WiggleManagerTag });
}
});
}
export async function clearStructureWiggle(plugin: PluginContext, components: StructureComponentRef[], types?: string[]) {
await eachRepr(plugin, components, async (update, repr, wiggleCell) => {
if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return;
if (wiggleCell) {
update.delete(wiggleCell.transform.ref);
}
});
}
async function eachRepr(plugin: PluginContext, components: StructureComponentRef[], callback: WiggleEachReprCallback) {
const state = plugin.state.data;
const update = state.build();
for (const c of components) {
for (const r of c.representations) {
const wiggle = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.WiggleStructureRepresentation3DFromBundle, r.cell.transform.ref).withTag(WiggleManagerTag));
await callback(update, r.cell, wiggle[0]);
}
}
return update.commit({ doNotUpdateCurrent: true });
}
/** filter wiggle layers for given structure */
function getFilteredBundle(layers: Wiggle.BundleLayer[], structure: Structure) {
const wiggle = Wiggle.ofBundle(layers, structure.root);
const merged = Wiggle.merge(wiggle);
return Wiggle.filter(merged, structure) as Wiggle<StructureElement.Loci>;
}
function getUncertaintyValue(unit: Unit, element: number): number {
if (Unit.isAtomic(unit)) {
return unit.model.atomicConformation.B_iso_or_equiv.value(element);
} else if (Unit.isSpheres(unit)) {
return unit.model.coarseConformation.spheres.rmsf[element];
}
return 0;
}
/** Compute min/max uncertainty (B-factor or RMSF) across all units in a structure */
function getUncertaintyRange(structure: Structure): { min: number, max: number } {
let min = Infinity;
let max = -Infinity;
for (const unit of structure.units) {
const elements = unit.elements;
for (let j = 0, jl = elements.length; j < jl; j++) {
const v = getUncertaintyValue(unit, elements[j]);
if (v < min) min = v;
if (v > max) max = v;
}
}
if (!isFinite(min)) min = 0;
if (!isFinite(max)) max = 0;
return { min, max };
}
/**
* Set per-group wiggle based on B-factor/RMSF uncertainty data.
* Values are normalized to [0, 1] within each structure's min-max range.
* @param scale - maximum wiggle value (default 1.0, corresponds to Angstroms when combined with wiggleAmplitude)
*/
export async function setStructureWiggleFromUncertainty(plugin: PluginContext, components: StructureComponentRef[], scale: number = 1, types?: string[]) {
await eachRepr(plugin, components, async (update, repr, wiggleCell) => {
if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return;
const structure = repr.obj!.data.sourceData;
const root = structure.root;
const { min, max } = getUncertaintyRange(root);
const range = max - min;
if (range <= 0) return;
// Group elements by discretized uncertainty bucket (256 levels for Uint8 texture)
const buckets = new Map<number, { unit: Unit, indices: number[] }[]>();
for (const unit of root.units) {
const elements = unit.elements;
const unitBuckets = new Map<number, number[]>();
for (let j = 0, jl = elements.length; j < jl; j++) {
const v = getUncertaintyValue(unit, elements[j]);
const normalized = (v - min) / range;
const bucket = Math.min(255, Math.round(normalized * 255));
if (!unitBuckets.has(bucket)) unitBuckets.set(bucket, []);
unitBuckets.get(bucket)!.push(j);
}
for (const [bucket, indices] of unitBuckets) {
if (!buckets.has(bucket)) buckets.set(bucket, []);
buckets.get(bucket)!.push({ unit, indices });
}
}
// Create one layer per bucket
const bundleLayers: Wiggle.BundleLayer[] = [];
for (const [bucket, unitIndices] of buckets) {
const value = (bucket / 255) * scale;
const elements: StructureElement.Loci['elements'][0][] = [];
for (const { unit, indices } of unitIndices) {
elements.push({
unit,
indices: OrderedSet.ofSortedArray(new Int32Array(indices) as any as StructureElement.UnitIndex[]),
});
}
const loci = StructureElement.Loci(root, elements);
if (!StructureElement.Loci.isEmpty(loci)) {
bundleLayers.push({
bundle: StructureElement.Bundle.fromLoci(loci),
value,
});
}
}
if (bundleLayers.length === 0) return;
const filtered = getFilteredBundle(bundleLayers, structure);
if (wiggleCell) {
update.to(wiggleCell).update(Wiggle.toBundle(filtered));
} else {
update.to(repr.transform.ref)
.apply(StateTransforms.Representation.WiggleStructureRepresentation3DFromBundle, Wiggle.toBundle(filtered), { tags: WiggleManagerTag });
}
});
}

View File

@@ -36,7 +36,7 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
get animations() { return this._animations; }
get isAnimatingStateTransition() {
return this._current.anim.name === 'built-in.animate-state-snapshot-transition';
return !!this._current && this._current.anim.name === 'built-in.animate-state-snapshot-transition';
}
private triggerUpdate() {

View File

@@ -35,7 +35,9 @@ import { setStructureSubstance } from '../../helpers/structure-substance';
import { Material } from '../../../mol-util/material';
import { Clip } from '../../../mol-util/clip';
import { setStructureEmissive } from '../../helpers/structure-emissive';
import { setStructureWiggle } from '../../helpers/structure-wiggle';
import { areInteriorPropsEquals, getInteriorParam } from '../../../mol-geo/geometry/interior';
import { areAnimationPropsEqual, getAnimationParam } from '../../../mol-geo/geometry/animation';
export { StructureComponentManager };
@@ -84,13 +86,14 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
p.material = options.materialStyle;
p.clip = options.clipObjects;
p.interior = options.interior;
p.animation = options.animation;
});
if (interactionChanged) await this.updateInterationProps();
});
}
private updateReprParams(update: StateBuilder.Root, component: StructureComponentRef) {
const { hydrogens, visualQuality: quality, ignoreLight, materialStyle: material, clipObjects: clip, interior } = this.state.options;
const { hydrogens, visualQuality: quality, ignoreLight, materialStyle: material, clipObjects: clip, interior, animation } = this.state.options;
const ignoreHydrogens = hydrogens !== 'all';
const ignoreHydrogensVariant = hydrogens === 'only-polar' ? 'non-polar' : 'all';
for (const r of component.representations) {
@@ -98,7 +101,8 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
const params = r.cell.transform.params as StateTransformer.Params<StructureRepresentation3D>;
const pInterior = params.type.params.interior;
if (!!params.type.params.ignoreHydrogens !== ignoreHydrogens || params.type.params.ignoreHydrogensVariant !== ignoreHydrogensVariant || params.type.params.quality !== quality || params.type.params.ignoreLight !== ignoreLight || !Material.areEqual(params.type.params.material, material) || !PD.areEqual(Clip.Params, params.type.params.clip, clip) || (pInterior && !areInteriorPropsEquals(pInterior, interior))) {
const pAnimation = params.type.params.animation;
if (!!params.type.params.ignoreHydrogens !== ignoreHydrogens || params.type.params.ignoreHydrogensVariant !== ignoreHydrogensVariant || params.type.params.quality !== quality || params.type.params.ignoreLight !== ignoreLight || !Material.areEqual(params.type.params.material, material) || !PD.areEqual(Clip.Params, params.type.params.clip, clip) || (pInterior && !areInteriorPropsEquals(pInterior, interior)) || (pAnimation && !areAnimationPropsEqual(pAnimation, animation))) {
update.to(r.cell).update(old => {
old.type.params.ignoreHydrogens = ignoreHydrogens;
old.type.params.ignoreHydrogensVariant = ignoreHydrogensVariant;
@@ -107,6 +111,7 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
old.type.params.material = material;
old.type.params.clip = clip;
if (pInterior) old.type.params.interior = interior;
if (pAnimation) old.type.params.animation = animation;
});
}
}
@@ -325,10 +330,10 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
addRepresentation(components: ReadonlyArray<StructureComponentRef>, type: string) {
if (components.length === 0) return;
const { hydrogens, visualQuality: quality, ignoreLight, materialStyle: material, clipObjects: clip, interior } = this.state.options;
const { hydrogens, visualQuality: quality, ignoreLight, materialStyle: material, clipObjects: clip, interior, animation } = this.state.options;
const ignoreHydrogens = hydrogens !== 'all';
const ignoreHydrogensVariant = hydrogens === 'only-polar' ? 'non-polar' : 'all';
const typeParams = { ignoreHydrogens, ignoreHydrogensVariant, quality, ignoreLight, material, clip, interior };
const typeParams = { ignoreHydrogens, ignoreHydrogensVariant, quality, ignoreLight, material, clip, interior, animation };
return this.plugin.dataTransaction(async () => {
for (const component of components) {
@@ -363,10 +368,10 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
const xs = structures || this.currentStructures;
if (xs.length === 0) return;
const { hydrogens, visualQuality: quality, ignoreLight, materialStyle: material, clipObjects: clip, interior } = this.state.options;
const { hydrogens, visualQuality: quality, ignoreLight, materialStyle: material, clipObjects: clip, interior, animation } = this.state.options;
const ignoreHydrogens = hydrogens !== 'all';
const ignoreHydrogensVariant = hydrogens === 'only-polar' ? 'non-polar' : 'all';
const typeParams = { ignoreHydrogens, ignoreHydrogensVariant, quality, ignoreLight, material, clip, interior };
const typeParams = { ignoreHydrogens, ignoreHydrogensVariant, quality, ignoreLight, material, clip, interior, animation };
const componentKey = UUID.create22();
for (const s of xs) {
@@ -417,6 +422,9 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
} else if (params.action.name === 'clipping') {
const p = params.action.params;
await setStructureClipping(this.plugin, s.components, Clipping.Groups.fromNames(p.excludeGroups), getLoci, params.representations);
} else if (params.action.name === 'wiggle') {
const p = params.action.params;
await setStructureWiggle(this.plugin, s.components, p.value, getLoci, params.representations);
}
}
}, { canUndo: 'Apply Theme' });
@@ -488,6 +496,7 @@ namespace StructureComponentManager {
clipObjects: PD.Group(Clip.Params),
interactions: PD.Group(InteractionsProvider.defaultParams, { label: 'Non-covalent Interactions' }),
interior: getInteriorParam(),
animation: getAnimationParam(),
};
export type Options = PD.Values<typeof OptionsParams>
@@ -533,6 +542,9 @@ namespace StructureComponentManager {
clipping: PD.Group({
excludeGroups: PD.MultiSelect([] as Clipping.Groups.Names[], PD.objectToOptions(Clipping.Groups.Names)),
}, { isFlat: true }),
wiggle: PD.Group({
value: PD.Numeric(1, { min: 0, max: 5, step: 0.01 }),
}, { isFlat: true }),
}),
representations: PD.MultiSelect([], getRepresentationTypes(plugin, pivot), { emptyValue: 'All' })
};

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>
@@ -10,6 +10,7 @@ import { CifFile } from '../mol-io/reader/cif';
import { DcdFile } from '../mol-io/reader/dcd/parser';
import { Dsn6File } from '../mol-io/reader/dsn6/schema';
import { PlyFile } from '../mol-io/reader/ply/schema';
import { ObjFile } from '../mol-io/reader/obj/schema';
import { PsfFile } from '../mol-io/reader/psf/parser';
import { ShapeProvider } from '../mol-model/shape/provider';
import { Coordinates as _Coordinates, Model as _Model, Structure as _Structure, Trajectory as _Trajectory, StructureElement, Topology as _Topology } from '../mol-model/structure';
@@ -79,6 +80,7 @@ export namespace PluginStateObject {
export class Prmtop extends Create<PrmtopFile>({ name: 'PRMTOP File', typeClass: 'Data' }) { }
export class Top extends Create<TopFile>({ name: 'TOP File', typeClass: 'Data' }) { }
export class Ply extends Create<PlyFile>({ name: 'PLY File', typeClass: 'Data' }) { }
export class Obj extends Create<ObjFile>({ name: 'OBJ File', typeClass: 'Data' }) { }
export class Ccp4 extends Create<Ccp4File>({ name: 'CCP4/MRC/MAP File', typeClass: 'Data' }) { }
export class Dsn6 extends Create<Dsn6File>({ name: 'DSN6/BRIX File', typeClass: 'Data' }) { }
export class Dx extends Create<DxFile>({ name: 'DX File', typeClass: 'Data' }) { }

View File

@@ -1,5 +1,5 @@
/**
* 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>
@@ -10,6 +10,7 @@ import * as CCP4 from '../../mol-io/reader/ccp4/parser';
import { CIF } from '../../mol-io/reader/cif';
import * as DSN6 from '../../mol-io/reader/dsn6/parser';
import * as PLY from '../../mol-io/reader/ply/parser';
import * as OBJ from '../../mol-io/reader/obj/parser';
import { parsePsf } from '../../mol-io/reader/psf/parser';
import { PluginContext } from '../../mol-plugin/context';
import { StateObject, StateTransformer } from '../../mol-state';
@@ -41,6 +42,7 @@ export { ParsePsf };
export { ParsePrmtop };
export { ParseTop };
export { ParsePly };
export { ParseObj };
export { ParseCcp4 };
export { ParseDsn6 };
export { ParseDx };
@@ -403,6 +405,22 @@ const ParsePly = PluginStateTransform.BuiltIn({
}
});
type ParseObj = typeof ParseObj
const ParseObj = PluginStateTransform.BuiltIn({
name: 'parse-obj',
display: { name: 'Parse OBJ', description: 'Parse OBJ from String data' },
from: [SO.Data.String],
to: SO.Format.Obj
})({
apply({ a }) {
return Task.create('Parse OBJ', async ctx => {
const parsed = await OBJ.parseObj(a.data).runInContext(ctx);
if (parsed.isError) throw new Error(parsed.message);
return new SO.Format.Obj(parsed.result, { label: 'OBJ Data' });
});
}
});
type ParseCcp4 = typeof ParseCcp4
const ParseCcp4 = PluginStateTransform.BuiltIn({
name: 'parse-ccp4',

View File

@@ -10,8 +10,7 @@
import { parseDcd } from '../../mol-io/reader/dcd/parser';
import { parseGRO } from '../../mol-io/reader/gro/parser';
import { parsePDB } from '../../mol-io/reader/pdb/parser';
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
import { shapeFromPly } from '../../mol-model-formats/shape/ply';
import { Vec3 } from '../../mol-math/linear-algebra';
import { coordinatesFromDcd } from '../../mol-model-formats/structure/dcd';
import { trajectoryFromGRO } from '../../mol-model-formats/structure/gro';
import { trajectoryFromCCD, trajectoryFromMmCIF } from '../../mol-model-formats/structure/mmcif';
@@ -93,7 +92,6 @@ export { StructureComplexElement };
export { StructureComponent };
export { CustomModelProperties };
export { CustomStructureProperties };
export { ShapeFromPly };
type CoordinatesFromDcd = typeof CoordinatesFromDcd
const CoordinatesFromDcd = PluginStateTransform.BuiltIn({
@@ -1295,25 +1293,3 @@ async function attachStructureProps(structure: Structure, ctx: PluginContext, ta
}
}
}
type ShapeFromPly = typeof ShapeFromPly
const ShapeFromPly = PluginStateTransform.BuiltIn({
name: 'shape-from-ply',
display: { name: 'Shape from PLY', description: 'Create Shape from PLY data' },
from: SO.Format.Ply,
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value<Mat4[]>([], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}
})({
apply({ a, params }) {
return Task.create('Create shape from PLY', async ctx => {
const shape = await shapeFromPly(a.data, params).runInContext(ctx);
const props = { label: params.label || 'Shape' };
return new SO.Shape.Provider(shape, props);
});
}
});

View File

@@ -45,6 +45,7 @@ import { Material } from '../../mol-util/material';
import { lerp } from '../../mol-math/interpolate';
import { MarkerAction, MarkerActions } from '../../mol-util/marker-action';
import { Emissive } from '../../mol-theme/emissive';
import { Wiggle } from '../../mol-theme/wiggle';
export { StructureRepresentation3D };
export { ExplodeStructureRepresentation3D };
@@ -60,6 +61,8 @@ export { SubstanceStructureRepresentation3DFromScript };
export { SubstanceStructureRepresentation3DFromBundle };
export { ClippingStructureRepresentation3DFromScript };
export { ClippingStructureRepresentation3DFromBundle };
export { WiggleStructureRepresentation3DFromScript };
export { WiggleStructureRepresentation3DFromBundle };
export { ThemeStrengthRepresentation3D };
export { VolumeRepresentation3D };
@@ -862,6 +865,109 @@ const ClippingStructureRepresentation3DFromBundle = PluginStateTransform.BuiltIn
}
});
type WiggleStructureRepresentation3DFromScript = typeof WiggleStructureRepresentation3DFromScript
const WiggleStructureRepresentation3DFromScript = PluginStateTransform.BuiltIn({
name: 'wiggle-structure-representation-3d-from-script',
display: 'Wiggle 3D Representation',
from: SO.Molecule.Structure.Representation3D,
to: SO.Molecule.Structure.Representation3DState,
params: () => ({
layers: PD.ObjectList({
script: PD.Script(Script('(sel.atom.all)', 'mol-script')),
value: PD.Numeric(5, { min: 0, max: 1, step: 0.01 }, { label: 'Wiggle' }),
}, e => `Wiggle (${e.value})`, {
defaultValue: [{
script: Script('(sel.atom.all)', 'mol-script'),
value: 1,
}]
})
})
})({
canAutoUpdate() {
return true;
},
apply({ a, params }) {
const structure = a.data.sourceData;
const geometryVersion = a.data.repr.geometryVersion;
const wiggle = Wiggle.ofScript(params.layers, structure);
return new SO.Molecule.Structure.Representation3DState({
state: { wiggle },
initialState: { wiggle: Wiggle.Empty },
info: { structure, geometryVersion },
repr: a.data.repr
}, { label: `Wiggle (${wiggle.layers.length} Layers)` });
},
update({ a, b, newParams, oldParams }) {
const info = b.data.info as { structure: Structure, geometryVersion: number };
const newStructure = a.data.sourceData;
if (newStructure !== info.structure) return StateTransformer.UpdateResult.Recreate;
if (a.data.repr !== b.data.repr) return StateTransformer.UpdateResult.Recreate;
const oldWiggle = b.data.state.wiggle!;
const newWiggle = Wiggle.ofScript(newParams.layers, newStructure);
if (Wiggle.areEqual(oldWiggle, newWiggle)) return StateTransformer.UpdateResult.Unchanged;
info.geometryVersion = a.data.repr.geometryVersion;
b.data.state.wiggle = newWiggle;
b.data.repr = a.data.repr;
b.label = `Wiggle (${newWiggle.layers.length} Layers)`;
return StateTransformer.UpdateResult.Updated;
}
});
type WiggleStructureRepresentation3DFromBundle = typeof WiggleStructureRepresentation3DFromBundle
const WiggleStructureRepresentation3DFromBundle = PluginStateTransform.BuiltIn({
name: 'wiggle-structure-representation-3d-from-bundle',
display: 'Wiggle 3D Representation',
from: SO.Molecule.Structure.Representation3D,
to: SO.Molecule.Structure.Representation3DState,
params: () => ({
layers: PD.ObjectList({
bundle: PD.Value<StructureElement.Bundle>(StructureElement.Bundle.Empty),
value: PD.Numeric(5, { min: 0, max: 1, step: 0.01 }, { label: 'Wiggle' }),
}, e => `Wiggle (${e.value})`, {
defaultValue: [{
bundle: StructureElement.Bundle.Empty,
value: 1,
}],
isHidden: true
})
})
})({
canAutoUpdate() {
return true;
},
apply({ a, params }) {
const structure = a.data.sourceData;
const geometryVersion = a.data.repr.geometryVersion;
const wiggle = Wiggle.ofBundle(params.layers, structure);
return new SO.Molecule.Structure.Representation3DState({
state: { wiggle },
initialState: { wiggle: Wiggle.Empty },
info: { structure, geometryVersion },
repr: a.data.repr
}, { label: `Wiggle (${wiggle.layers.length} Layers)` });
},
update({ a, b, newParams, oldParams }) {
const info = b.data.info as { structure: Structure, geometryVersion: number };
const newStructure = a.data.sourceData;
if (newStructure !== info.structure) return StateTransformer.UpdateResult.Recreate;
if (a.data.repr !== b.data.repr) return StateTransformer.UpdateResult.Recreate;
const oldWiggle = b.data.state.wiggle!;
const newWiggle = Wiggle.ofBundle(newParams.layers, newStructure);
if (Wiggle.areEqual(oldWiggle, newWiggle)) return StateTransformer.UpdateResult.Unchanged;
info.geometryVersion = a.data.repr.geometryVersion;
b.data.state.wiggle = newWiggle;
b.data.repr = a.data.repr;
b.label = `Wiggle (${newWiggle.layers.length} Layers)`;
return StateTransformer.UpdateResult.Updated;
}
});
type ThemeStrengthRepresentation3D = typeof ThemeStrengthRepresentation3D
const ThemeStrengthRepresentation3D = PluginStateTransform.BuiltIn({
name: 'theme-strength-representation-3d',
@@ -873,6 +979,7 @@ const ThemeStrengthRepresentation3D = PluginStateTransform.BuiltIn({
transparencyStrength: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }),
emissiveStrength: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }),
substanceStrength: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }),
wiggleStrength: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }),
})
})({
canAutoUpdate() {
@@ -885,21 +992,23 @@ const ThemeStrengthRepresentation3D = PluginStateTransform.BuiltIn({
overpaint: params.overpaintStrength,
transparency: params.transparencyStrength,
emissive: params.emissiveStrength,
substance: params.substanceStrength
substance: params.substanceStrength,
wiggle: params.wiggleStrength,
},
},
initialState: {
themeStrength: { overpaint: 1, transparency: 1, emissive: 1, substance: 1 },
themeStrength: { overpaint: 1, transparency: 1, emissive: 1, substance: 1, wiggle: 1 },
},
info: { },
repr: a.data.repr
}, { label: 'Theme Strength', description: `${params.overpaintStrength.toFixed(2)}, ${params.transparencyStrength.toFixed(2)}, ${params.emissiveStrength.toFixed(2)}, ${params.substanceStrength.toFixed(2)}` });
}, { label: 'Theme Strength', description: `${params.overpaintStrength.toFixed(2)}, ${params.transparencyStrength.toFixed(2)}, ${params.emissiveStrength.toFixed(2)}, ${params.substanceStrength.toFixed(2)}, ${params.wiggleStrength.toFixed(2)}` });
},
update({ a, b, newParams, oldParams }) {
if (newParams.overpaintStrength === b.data.state.themeStrength?.overpaint &&
newParams.transparencyStrength === b.data.state.themeStrength?.transparency &&
newParams.emissiveStrength === b.data.state.themeStrength?.emissive &&
newParams.substanceStrength === b.data.state.themeStrength?.substance
newParams.substanceStrength === b.data.state.themeStrength?.substance &&
newParams.wiggleStrength === b.data.state.themeStrength?.wiggle
) return StateTransformer.UpdateResult.Unchanged;
b.data.state.themeStrength = {
@@ -907,10 +1016,11 @@ const ThemeStrengthRepresentation3D = PluginStateTransform.BuiltIn({
transparency: newParams.transparencyStrength,
emissive: newParams.emissiveStrength,
substance: newParams.substanceStrength,
wiggle: newParams.wiggleStrength,
};
b.data.repr = a.data.repr;
b.label = 'Theme Strength';
b.description = `${newParams.overpaintStrength.toFixed(2)}, ${newParams.transparencyStrength.toFixed(2)}, ${newParams.emissiveStrength.toFixed(2)}, ${newParams.substanceStrength.toFixed(2)}`;
b.description = `${newParams.overpaintStrength.toFixed(2)}, ${newParams.transparencyStrength.toFixed(2)}, ${newParams.emissiveStrength.toFixed(2)}, ${newParams.substanceStrength.toFixed(2)}, ${newParams.wiggleStrength.toFixed(2)}`;
return StateTransformer.UpdateResult.Updated;
},
interpolate(src, tar, t) {
@@ -919,6 +1029,7 @@ const ThemeStrengthRepresentation3D = PluginStateTransform.BuiltIn({
transparencyStrength: lerp(src.transparencyStrength, tar.transparencyStrength, t),
emissiveStrength: lerp(src.emissiveStrength, tar.emissiveStrength, t),
substanceStrength: lerp(src.substanceStrength, tar.substanceStrength, t),
wiggleStrength: lerp(src.wiggleStrength, tar.wiggleStrength, t),
};
}
});

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2021-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 { Mesh } from '../../mol-geo/geometry/mesh/mesh';
@@ -9,6 +10,8 @@ import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { BoxCage } from '../../mol-geo/primitive/box';
import { Box3D, Sphere3D } from '../../mol-math/geometry';
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
import { shapeFromObj } from '../../mol-model-formats/shape/obj';
import { shapeFromPly } from '../../mol-model-formats/shape/ply';
import { Shape } from '../../mol-model/shape';
import { Task } from '../../mol-task';
import { ColorNames } from '../../mol-util/color/names';
@@ -67,3 +70,49 @@ export function getBoxMesh(box: Box3D, radius: number, oldMesh?: Mesh) {
return mesh;
}
export { ShapeFromPly };
type ShapeFromPly = typeof ShapeFromPly
const ShapeFromPly = PluginStateTransform.BuiltIn({
name: 'shape-from-ply',
display: { name: 'Shape from PLY', description: 'Create Shape from PLY data' },
from: SO.Format.Ply,
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}
})({
apply({ a, params }) {
return Task.create('Create shape from PLY', async ctx => {
const shape = await shapeFromPly(a.data, params).runInContext(ctx);
const props = { label: params.label || 'Shape' };
return new SO.Shape.Provider(shape, props);
});
}
});
export { ShapeFromObj };
type ShapeFromObj = typeof ShapeFromObj
const ShapeFromObj = PluginStateTransform.BuiltIn({
name: 'shape-from-obj',
display: { name: 'Shape from OBJ', description: 'Create Shape from OBJ data' },
from: SO.Format.Obj,
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}
})({
apply({ a, params }) {
return Task.create('Create shape from OBJ', async ctx => {
const shape = await shapeFromObj(a.data, params).runInContext(ctx);
const props = { label: params.label || 'Shape' };
return new SO.Shape.Provider(shape, props);
});
}
});

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>
@@ -26,6 +26,7 @@ import { VolumeStreamingControls, VolumeSourceControls } from './structure/volum
import { PluginConfig } from '../mol-plugin/config';
import { StructureSuperpositionControls } from './structure/superposition';
import { StructureQuickStylesControls } from './structure/quick-styles';
import { StructureProceduralAnimationControls } from './structure/procedural-animation';
import { Markdown } from './controls/markdown';
import { Slider } from './controls/slider';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
@@ -336,6 +337,7 @@ export class DefaultStructureTools extends PluginUIComponent {
<StructureMeasurementsControls />
<StructureSuperpositionControls />
<StructureQuickStylesControls />
<StructureProceduralAnimationControls />
<StructureComponentControls />
{this.plugin.config.get(PluginConfig.VolumeStreaming.Enabled) && <VolumeStreamingControls />}
<VolumeSourceControls />

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

@@ -181,8 +181,8 @@ export class Slider2 extends React.Component<{
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
function classNames(_classes: { [name: string]: boolean | number }) {
const classes = [];
function classNames(_classes: { [name: string]: boolean | number }): string {
const classes: string[] = [];
const hasOwn = {}.hasOwnProperty;
for (let i = 0; i < arguments.length; i++) {
@@ -194,7 +194,7 @@ function classNames(_classes: { [name: string]: boolean | number }) {
if (argType === 'string' || argType === 'number') {
classes.push(arg);
} else if (Array.isArray(arg)) {
classes.push(classNames.apply(null, arg));
classes.push(classNames.apply(null, arg as any));
} else if (argType === 'object') {
for (const key in arg) {
if (hasOwn.call(arg, key) && arg[key]) {
@@ -290,6 +290,7 @@ export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState
const defaultValue = ('defaultValue' in props ? props.defaultValue : initialValue);
const value = (props.value !== undefined ? props.value : defaultValue);
// @ts-ignore
const bounds = (range ? value : [min, value]).map((v: number) => this.trimAlignValue(v));
let recent;

View File

@@ -0,0 +1,113 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { clearStructureWiggle, setStructureWiggleFromUncertainty } from '../../mol-plugin-state/helpers/structure-wiggle';
import { CollapsableControls, PurePluginUIComponent } from '../base';
import { Button } from '../controls/common';
import { AnimationSvg } from '../controls/icons';
export class StructureProceduralAnimationControls extends CollapsableControls {
defaultState() {
return {
isCollapsed: false,
header: 'Procedural Animation',
brand: { accent: 'gray' as const, svg: AnimationSvg }
};
}
renderControls() {
return <StructureProceduralAnimation />;
}
}
interface StructureProceduralAnimationState {
busy: boolean;
}
class StructureProceduralAnimation extends PurePluginUIComponent<{}, StructureProceduralAnimationState> {
state: StructureProceduralAnimationState = { busy: false };
private get components() {
return this.plugin.managers.structure.hierarchy.selection.structures.flatMap(s => s.components);
}
async applyUncertaintyWiggle() {
this.setState({ busy: true });
try {
const options = this.plugin.managers.structure.component.state.options;
await this.plugin.managers.structure.component.setOptions({
...options,
animation: {
...options.animation,
wiggleAmplitude: 0,
tumbleAmplitude: 0
}
});
await setStructureWiggleFromUncertainty(this.plugin, this.components);
} finally {
this.setState({ busy: false });
}
}
async applyDynamics() {
this.setState({ busy: true });
try {
const options = this.plugin.managers.structure.component.state.options;
await this.plugin.managers.structure.component.setOptions({
...options,
animation: {
...options.animation,
wiggleSpeed: 7,
wiggleAmplitude: 1,
wiggleFrequency: 0.2,
}
});
await clearStructureWiggle(this.plugin, this.components);
} finally {
this.setState({ busy: false });
}
}
async clearWiggle() {
this.setState({ busy: true });
try {
const options = this.plugin.managers.structure.component.state.options;
await this.plugin.managers.structure.component.setOptions({
...options,
animation: {
...options.animation,
wiggleAmplitude: 0,
tumbleAmplitude: 0
}
});
await clearStructureWiggle(this.plugin, this.components);
} finally {
this.setState({ busy: false });
}
}
render() {
return <>
<div className='msp-control-group-wrapper'>
<div className='msp-control-group-header'><div><b>Apply Wiggle</b></div></div>
<div className='msp-flex-row'>
<Button title='Set wiggle speed to 8 and amplitude to 1'
onClick={() => this.applyDynamics()} disabled={this.state.busy} >
Dynamics
</Button>
<Button title='Set per-group wiggle amplitude based on B-factor / RMSF uncertainty'
onClick={() => this.applyUncertaintyWiggle()} disabled={this.state.busy} >
Uncertainty
</Button>
<Button title='Set wiggle/tumble amplitude to zero and remove per-group wiggle layers'
onClick={() => this.clearWiggle()} disabled={this.state.busy} >
Clear
</Button>
</div>
</div>
</>;
}
}

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>
@@ -21,6 +21,7 @@ import { PluginContext } from '../../../context';
import { Material } from '../../../../mol-util/material';
import { Clip } from '../../../../mol-util/clip';
import { getInteriorParam } from '../../../../mol-geo/geometry/interior';
import { getAnimationParam } from '../../../../mol-geo/geometry/animation';
const StructureFocusRepresentationParams = (plugin: PluginContext) => {
const reprParams = StateTransforms.Representation.StructureRepresentation3D.definition.params!(void 0, plugin) as PD.Params;
@@ -58,6 +59,7 @@ const StructureFocusRepresentationParams = (plugin: PluginContext) => {
material: Material.getParam(),
clip: PD.Group(Clip.Params),
interior: getInteriorParam(),
animation: getAnimationParam(),
};
};
@@ -83,7 +85,7 @@ class StructureFocusRepresentationBehavior extends PluginBehavior.WithSubscriber
...reprParams,
type: {
name: reprParams.type.name,
params: { ...reprParams.type.params, ignoreHydrogens: this.params.ignoreHydrogens, ignoreHydrogensVariant: this.params.ignoreHydrogensVariant, ignoreLight: this.params.ignoreLight, material: this.params.material, clip: this.params.clip, interior: this.params.interior }
params: { ...reprParams.type.params, ignoreHydrogens: this.params.ignoreHydrogens, ignoreHydrogensVariant: this.params.ignoreHydrogensVariant, ignoreLight: this.params.ignoreLight, material: this.params.material, clip: this.params.clip, interior: this.params.interior, animation: this.params.animation }
}
};
}

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