Compare commits

...

24 Commits

Author SHA1 Message Date
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
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
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
f806ac1444 Add axis param to camera spin/rock animation 2026-05-16 22:25:55 -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
14 changed files with 1105 additions and 30 deletions

View File

@@ -16,7 +16,10 @@ Note that since we don't clearly distinguish between a public and private interf
- 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
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

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

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

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

@@ -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' };