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

Trying to stick with the master code, which has changed a lot.
This commit is contained in:
Russ Taylor
2026-02-03 10:59:53 -05:00
parent 1f0c24b58e
commit 78ab6b0c95
4 changed files with 1055 additions and 0 deletions

View File

@@ -0,0 +1,627 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { Structure, StructureElement, StructureProperties, Unit } from '../../mol-model/structure';
import { Task, RuntimeContext } from '../../mol-task';
import { CentroidHelper } from '../../mol-math/geometry/centroid-helper';
import { AccessibleSurfaceAreaParams } from '../../mol-model-props/computed/accessible-surface-area';
import { Vec3 } from '../../mol-math/linear-algebra';
import { getElementMoleculeType } from '../../mol-model/structure/util';
import { MoleculeType } from '../../mol-model/structure/model/types';
import { AccessibleSurfaceArea } from '../../mol-model-props/computed/accessible-surface-area/shrake-rupley';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { KinemageData } from './prop';
const LARGE_CA_THRESHOLD = 5000;
const DEFAULT_UPDATE_INTERVAL = 10;
const LARGE_CA_UPDATE_INTERVAL = 1;
interface KinemageContext {
structure: Structure,
numberOfSpherePoints: number,
stepSize: number,
minThickness: number,
maxThickness: number,
asaCutoff: number,
adjust: number,
offsets: ArrayLike<number>,
exposed: ArrayLike<number>,
hydrophobic: ArrayLike<boolean>,
centroid: Vec3,
extent: number,
large: boolean
};
export const KinemageParams = {
/// @todo Replace these
numberOfSpherePoints: PD.Numeric(175, { min: 35, max: 700, step: 1 }, { description: 'Number of spheres/directions to test for membrane placement. Original value is 350.' }),
stepSize: PD.Numeric(1, { min: 0.25, max: 4, step: 0.25 }, { description: 'Thickness of membrane slices that will be tested' }),
minThickness: PD.Numeric(20, { min: 10, max: 30, step: 1 }, { description: 'Minimum membrane thickness used during refinement' }),
maxThickness: PD.Numeric(40, { min: 30, max: 50, step: 1 }, { description: 'Maximum membrane thickness used during refinement' }),
asaCutoff: PD.Numeric(40, { min: 10, max: 100, step: 1 }, { description: 'Relative ASA cutoff above which residues will be considered' }),
adjust: PD.Numeric(14, { min: 0, max: 30, step: 1 }, { description: 'Minimum length of membrane-spanning regions (original values: 14 for alpha-helices and 5 for beta sheets). Set to 0 to not optimize membrane thickness.' }),
tmdetDefinition: PD.Boolean(false, { description: `Use TMDET's classification of membrane-favoring amino acids. TMDET's classification shows better performance on porins and other beta-barrel structures.` })
};
export type KinemageParams = typeof KinemageParams
export type KinemageProps = PD.Values<KinemageParams>
/** ANVIL-specific (not general) definition of membrane-favoring amino acids */
/// @todo We don't need these
const Kinemage_DEFINITION = new Set(['ALA', 'CYS', 'GLY', 'HIS', 'ILE', 'LEU', 'MET', 'PHE', 'SER', 'TRP', 'VAL']);
/** TMDET-specific (not general) definition of membrane-favoring amino acids */
const TMDET_DEFINITION = new Set(['LEU', 'ILE', 'VAL', 'PHE', 'MET', 'GLY', 'TRP', 'TYR']);
/**
* Implements:
* Membrane positioning for high- and low-resolution protein structures through a binary classification approach
* Guillaume Postic, Yassine Ghouzam, Vincent Guiraud, and Jean-Christophe Gelly
* Protein Engineering, Design & Selection, 2015, 15
* doi: 10.1093/protein/gzv063
*
* ANVIL is derived from TMDET, the corresponding classification of hydrophobic amino acids is provided as optional parameter:
* Gabor E. Tusnady, Zsuzsanna Dosztanyi and Istvan Simon
* Transmembrane proteins in the Protein Data Bank: identification and classification
* Bioinformatics, 2004, 2964-2972
* doi: 10.1093/bioinformatics/bth340
*/
export function computeKinemage(structure: Structure, props: KinemageProps) {
return Task.create('Compute Membrane Orientation', async runtime => {
return await calculate(runtime, structure, props);
});
}
// avoiding namespace lookup improved performance in Chrome (Aug 2020)
const v3add = Vec3.add;
const v3clone = Vec3.clone;
const v3create = Vec3.create;
const v3distance = Vec3.distance;
const v3dot = Vec3.dot;
const v3magnitude = Vec3.magnitude;
const v3normalize = Vec3.normalize;
const v3scale = Vec3.scale;
const v3scaleAndAdd = Vec3.scaleAndAdd;
const v3set = Vec3.set;
const v3squaredDistance = Vec3.squaredDistance;
const v3sub = Vec3.sub;
const v3zero = Vec3.zero;
const centroidHelper = new CentroidHelper();
async function initialize(structure: Structure, props: KinemageProps, accessibleSurfaceArea: AccessibleSurfaceArea): Promise<KinemageContext> {
const l = StructureElement.Location.create(structure);
const { label_atom_id, label_comp_id, x, y, z } = StructureProperties.atom;
const asaCutoff = props.asaCutoff / 100;
centroidHelper.reset();
const offsets = new Array<number>();
const exposed = new Array<number>();
const hydrophobic = new Array<boolean>();
const definition = props.tmdetDefinition ? TMDET_DEFINITION : Kinemage_DEFINITION;
function isPartOfEntity(l: StructureElement.Location): boolean {
return !Unit.isAtomic(l.unit) ? notAtomic() : l.unit.model.atomicHierarchy.residues.label_seq_id.valueKind(l.unit.residueIndex[l.element]) === 0;
}
const vec = v3zero();
for (let i = 0, il = structure.units.length; i < il; ++i) {
const unit = structure.units[i];
const { elements } = unit;
l.unit = unit;
for (let j = 0, jl = elements.length; j < jl; ++j) {
const eI = elements[j];
l.element = eI;
// consider only amino acids in chains
if (getElementMoleculeType(unit, eI) !== MoleculeType.Protein || !isPartOfEntity(l)) {
continue;
}
// only CA is considered for downstream operations
if (label_atom_id(l) !== 'CA' && label_atom_id(l) !== 'BB') {
continue;
}
// original Kinemage only considers canonical amino acids
if (!MaxAsa[label_comp_id(l)]) {
continue;
}
// while iterating use first pass to compute centroid
v3set(vec, x(l), y(l), z(l));
centroidHelper.includeStep(vec);
// keep track of offsets and exposed state to reuse
offsets.push(structure.serialMapping.getSerialIndex(l.unit, l.element));
if (AccessibleSurfaceArea.getValue(l, accessibleSurfaceArea) / MaxAsa[label_comp_id(l)] > asaCutoff) {
exposed.push(structure.serialMapping.getSerialIndex(l.unit, l.element));
hydrophobic.push(isHydrophobic(definition, label_comp_id(l)));
}
}
}
// calculate centroid and extent
centroidHelper.finishedIncludeStep();
const centroid = v3clone(centroidHelper.center);
for (let k = 0, kl = offsets.length; k < kl; k++) {
setLocation(l, structure, offsets[k]);
v3set(vec, x(l), y(l), z(l));
centroidHelper.radiusStep(vec);
}
const extent = 1.2 * Math.sqrt(centroidHelper.radiusSq);
return {
...props,
structure,
offsets,
exposed,
hydrophobic,
centroid,
extent,
large: offsets.length > LARGE_CA_THRESHOLD
};
}
export async function calculate(runtime: RuntimeContext, structure: Structure, params: KinemageProps): Promise<KinemageData> {
// can't get away with the default 92 points here
const asaProps = { ...PD.getDefaultValues(AccessibleSurfaceAreaParams), probeSize: 4.0, traceOnly: true, numberOfSpherePoints: 184 };
const accessibleSurfaceArea = await AccessibleSurfaceArea.compute(structure, asaProps).runInContext(runtime);
const ctx = await initialize(structure, params, accessibleSurfaceArea);
const initialHphobHphil = HphobHphil.initial(ctx);
const initialMembrane = (await findMembrane(runtime, 'Placing initial membrane...', ctx, generateSpherePoints(ctx, ctx.numberOfSpherePoints), initialHphobHphil))!;
const refinedMembrane = (await findMembrane(runtime, 'Refining membrane placement...', ctx, findProximateAxes(ctx, initialMembrane), initialHphobHphil))!;
let membrane = initialMembrane.qmax! > refinedMembrane.qmax! ? initialMembrane : refinedMembrane;
if (ctx.adjust && !ctx.large) {
membrane = await adjustThickness(runtime, 'Adjusting membrane thickness...', ctx, membrane, initialHphobHphil);
}
const normalVector = v3zero();
const center = v3zero();
v3sub(normalVector, membrane.planePoint1, membrane.planePoint2);
v3normalize(normalVector, normalVector);
v3add(center, membrane.planePoint1, membrane.planePoint2);
v3scale(center, center, 0.5);
const extent = adjustExtent(ctx, membrane, center);
return {
planePoint1: membrane.planePoint1,
planePoint2: membrane.planePoint2,
normalVector,
centroid: center,
radius: extent
};
}
interface MembraneCandidate {
planePoint1: Vec3,
planePoint2: Vec3,
stats: HphobHphil,
normalVector?: Vec3,
spherePoint?: Vec3,
qmax?: number
}
namespace MembraneCandidate {
export function initial(c1: Vec3, c2: Vec3, stats: HphobHphil): MembraneCandidate {
return {
planePoint1: c1,
planePoint2: c2,
stats
};
}
export function scored(spherePoint: Vec3, planePoint1: Vec3, planePoint2: Vec3, stats: HphobHphil, qmax: number, centroid: Vec3): MembraneCandidate {
const normalVector = v3zero();
v3sub(normalVector, centroid, spherePoint);
return {
planePoint1,
planePoint2,
stats,
normalVector,
spherePoint,
qmax
};
}
}
async function findMembrane(runtime: RuntimeContext, message: string | undefined, ctx: KinemageContext, spherePoints: Vec3[], initialStats: HphobHphil): Promise<MembraneCandidate | undefined> {
const { centroid, stepSize, minThickness, maxThickness, large } = ctx;
// best performing membrane
let membrane: MembraneCandidate | undefined;
// score of the best performing membrane
let qmax = 0;
// construct slices of thickness 1.0 along the axis connecting the centroid and the spherePoint
const diam = v3zero();
for (let n = 0, nl = spherePoints.length; n < nl; n++) {
if (runtime.shouldUpdate && message && (n + 1) % (large ? LARGE_CA_UPDATE_INTERVAL : DEFAULT_UPDATE_INTERVAL) === 0) {
await runtime.update({ message, current: (n + 1), max: nl });
}
const spherePoint = spherePoints[n];
v3sub(diam, centroid, spherePoint);
v3scale(diam, diam, 2);
const diamNorm = v3magnitude(diam);
const sliceStats = HphobHphil.sliced(ctx, stepSize, spherePoint, diam, diamNorm);
const qvartemp = [];
for (let i = 0, il = diamNorm - stepSize; i < il; i += stepSize) {
const c1 = v3zero();
const c2 = v3zero();
v3scaleAndAdd(c1, spherePoint, diam, i / diamNorm);
v3scaleAndAdd(c2, spherePoint, diam, (i + stepSize) / diamNorm);
// evaluate how well this membrane slice embeddeds the peculiar residues
const stats = sliceStats[Math.round(i / stepSize)];
qvartemp.push(MembraneCandidate.initial(c1, c2, stats));
}
let jmax = Math.floor((minThickness / stepSize) - 1);
for (let width = 0, widthl = maxThickness; width <= widthl;) {
for (let i = 0, il = qvartemp.length - 1 - jmax; i < il; i++) {
let hphob = 0;
let hphil = 0;
for (let j = 0; j < jmax; j++) {
const ij = qvartemp[i + j];
if (j === 0 || j === jmax - 1) {
hphob += Math.floor(0.5 * ij.stats.hphob);
hphil += 0.5 * ij.stats.hphil;
} else {
hphob += ij.stats.hphob;
hphil += ij.stats.hphil;
}
}
if (hphob !== 0) {
const stats = { hphob, hphil };
const qvaltest = qValue(stats, initialStats);
if (qvaltest >= qmax) {
qmax = qvaltest;
membrane = MembraneCandidate.scored(spherePoint, qvartemp[i].planePoint1, qvartemp[i + jmax].planePoint2, stats, qmax, centroid);
}
}
}
jmax++;
width = (jmax + 1) * stepSize;
}
}
return membrane;
}
/** Adjust membrane thickness by maximizing the number of membrane segments. */
async function adjustThickness(runtime: RuntimeContext, message: string | undefined, ctx: KinemageContext, membrane: MembraneCandidate, initialHphobHphil: HphobHphil): Promise<MembraneCandidate> {
const { minThickness, large } = ctx;
const step = 0.3;
let maxThickness = v3distance(membrane.planePoint1, membrane.planePoint2);
let maxNos = membraneSegments(ctx, membrane).length;
let optimalThickness = membrane;
let n = 0;
const nl = Math.ceil((maxThickness - minThickness) / step);
while (maxThickness > minThickness) {
n++;
if (runtime.shouldUpdate && message && n % (large ? LARGE_CA_UPDATE_INTERVAL : DEFAULT_UPDATE_INTERVAL) === 0) {
await runtime.update({ message, current: n, max: nl });
}
const p = {
...ctx,
maxThickness,
stepSize: step
};
const temp = await findMembrane(runtime, void 0, p, [membrane.spherePoint!], initialHphobHphil);
if (temp) {
const nos = membraneSegments(ctx, temp).length;
if (nos > maxNos) {
maxNos = nos;
optimalThickness = temp;
}
}
maxThickness -= step;
}
return optimalThickness;
}
/** Report auth_seq_ids for all transmembrane segments. Will reject segments that are shorter than the adjust parameter specifies. Missing residues are considered in-membrane. */
function membraneSegments(ctx: KinemageContext, membrane: MembraneCandidate): ArrayLike<{ start: number, end: number }> {
const { offsets, structure, adjust } = ctx;
const { normalVector, planePoint1, planePoint2 } = membrane;
const { units } = structure;
const { elementIndices, unitIndices } = structure.serialMapping;
const testPoint = v3zero();
const { auth_seq_id } = StructureProperties.residue;
const d1 = -v3dot(normalVector!, planePoint1);
const d2 = -v3dot(normalVector!, planePoint2);
const dMin = Math.min(d1, d2);
const dMax = Math.max(d1, d2);
const inMembrane: { [k: string]: Set<number> } = Object.create(null);
const outMembrane: { [k: string]: Set<number> } = Object.create(null);
const segments: Array<{ start: number, end: number }> = [];
let authAsymId;
let lastAuthAsymId = null;
let authSeqId;
let lastAuthSeqId = units[0].model.atomicHierarchy.residues.auth_seq_id.value((units[0] as Unit.Atomic).chainIndex[0]) - 1;
let startOffset = 0;
let endOffset = 0;
// collect all residues in membrane layer
for (let k = 0, kl = offsets.length; k < kl; k++) {
const unit = units[unitIndices[offsets[k]]];
if (!Unit.isAtomic(unit)) notAtomic();
const elementIndex = elementIndices[offsets[k]];
authAsymId = unit.model.atomicHierarchy.chains.auth_asym_id.value(unit.chainIndex[elementIndex]);
if (authAsymId !== lastAuthAsymId) {
if (!inMembrane[authAsymId]) inMembrane[authAsymId] = new Set<number>();
if (!outMembrane[authAsymId]) outMembrane[authAsymId] = new Set<number>();
lastAuthAsymId = authAsymId;
}
authSeqId = unit.model.atomicHierarchy.residues.auth_seq_id.value(unit.residueIndex[elementIndex]);
v3set(testPoint, unit.conformation.x(elementIndex), unit.conformation.y(elementIndex), unit.conformation.z(elementIndex));
if (_isInMembranePlane(testPoint, normalVector!, dMin, dMax)) {
inMembrane[authAsymId].add(authSeqId);
} else {
outMembrane[authAsymId].add(authSeqId);
}
}
for (let k = 0, kl = offsets.length; k < kl; k++) {
const unit = units[unitIndices[offsets[k]]];
if (!Unit.isAtomic(unit)) notAtomic();
const elementIndex = elementIndices[offsets[k]];
authAsymId = unit.model.atomicHierarchy.chains.auth_asym_id.value(unit.chainIndex[elementIndex]);
authSeqId = unit.model.atomicHierarchy.residues.auth_seq_id.value(unit.residueIndex[elementIndex]);
if (inMembrane[authAsymId].has(authSeqId)) {
// chain change
if (authAsymId !== lastAuthAsymId) {
segments.push({ start: startOffset, end: endOffset });
lastAuthAsymId = authAsymId;
startOffset = k;
endOffset = k;
}
// sequence gaps
if (authSeqId !== lastAuthSeqId + 1) {
if (outMembrane[authAsymId].has(lastAuthSeqId + 1)) {
segments.push({ start: startOffset, end: endOffset });
startOffset = k;
}
lastAuthSeqId = authSeqId;
endOffset = k;
} else {
lastAuthSeqId++;
endOffset++;
}
}
}
segments.push({ start: startOffset, end: endOffset });
const l = StructureElement.Location.create(structure);
let startAuth;
let endAuth;
const refinedSegments: Array<{ start: number, end: number }> = [];
for (let k = 0, kl = segments.length; k < kl; k++) {
const { start, end } = segments[k];
if (start === 0 || end === offsets.length - 1) continue;
// evaluate residues 1 pos outside of membrane
setLocation(l, structure, offsets[start - 1]);
v3set(testPoint, l.unit.conformation.x(l.element), l.unit.conformation.y(l.element), l.unit.conformation.z(l.element));
const d3 = -v3dot(normalVector!, testPoint);
setLocation(l, structure, offsets[end + 1]);
v3set(testPoint, l.unit.conformation.x(l.element), l.unit.conformation.y(l.element), l.unit.conformation.z(l.element));
const d4 = -v3dot(normalVector!, testPoint);
if (Math.min(d3, d4) < dMin && Math.max(d3, d4) > dMax) {
// reject this refinement
setLocation(l, structure, offsets[start]);
startAuth = auth_seq_id(l);
setLocation(l, structure, offsets[end]);
endAuth = auth_seq_id(l);
if (Math.abs(startAuth - endAuth) + 1 < adjust) {
return [];
}
refinedSegments.push(segments[k]);
}
}
return refinedSegments;
}
function notAtomic(): never {
throw new Error('Property only available for atomic models.');
}
/** Filter for membrane residues and calculate the final extent of the membrane layer */
function adjustExtent(ctx: KinemageContext, membrane: MembraneCandidate, centroid: Vec3): number {
const { offsets, structure } = ctx;
const { normalVector, planePoint1, planePoint2 } = membrane;
const l = StructureElement.Location.create(structure);
const testPoint = v3zero();
const { x, y, z } = StructureProperties.atom;
const d1 = -v3dot(normalVector!, planePoint1);
const d2 = -v3dot(normalVector!, planePoint2);
const dMin = Math.min(d1, d2);
const dMax = Math.max(d1, d2);
let extent = 0;
for (let k = 0, kl = offsets.length; k < kl; k++) {
setLocation(l, structure, offsets[k]);
v3set(testPoint, x(l), y(l), z(l));
if (_isInMembranePlane(testPoint, normalVector!, dMin, dMax)) {
const dsq = v3squaredDistance(testPoint, centroid);
if (dsq > extent) extent = dsq;
}
}
return Math.sqrt(extent);
}
function qValue(currentStats: HphobHphil, initialStats: HphobHphil): number {
if (initialStats.hphob < 1) {
initialStats.hphob = 0.1;
}
if (initialStats.hphil < 1) {
initialStats.hphil += 1;
}
const part_tot = currentStats.hphob + currentStats.hphil;
return (currentStats.hphob * (initialStats.hphil - currentStats.hphil) - currentStats.hphil * (initialStats.hphob - currentStats.hphob)) /
Math.sqrt(part_tot * initialStats.hphob * initialStats.hphil * (initialStats.hphob + initialStats.hphil - part_tot));
}
export function isInMembranePlane(testPoint: Vec3, normalVector: Vec3, planePoint1: Vec3, planePoint2: Vec3): boolean {
const d1 = -v3dot(normalVector, planePoint1);
const d2 = -v3dot(normalVector, planePoint2);
return _isInMembranePlane(testPoint, normalVector, Math.min(d1, d2), Math.max(d1, d2));
}
function _isInMembranePlane(testPoint: Vec3, normalVector: Vec3, min: number, max: number): boolean {
const d = -v3dot(normalVector, testPoint);
return d > min && d < max;
}
/** Generates a defined number of points on a sphere with radius = extent around the specified centroid */
function generateSpherePoints(ctx: KinemageContext, numberOfSpherePoints: number): Vec3[] {
const { centroid, extent } = ctx;
const points = [];
let oldPhi = 0, h, theta, phi;
for (let k = 1, kl = numberOfSpherePoints + 1; k < kl; k++) {
h = -1 + 2 * (k - 1) / (2 * numberOfSpherePoints - 1);
theta = Math.acos(h);
phi = (k === 1 || k === numberOfSpherePoints) ? 0 : (oldPhi + 3.6 / Math.sqrt(2 * numberOfSpherePoints * (1 - h * h))) % (2 * Math.PI);
const point = v3create(
extent * Math.sin(phi) * Math.sin(theta) + centroid[0],
extent * Math.cos(theta) + centroid[1],
extent * Math.cos(phi) * Math.sin(theta) + centroid[2]
);
points[k - 1] = point;
oldPhi = phi;
}
return points;
}
/** Generates sphere points close to that of the initial membrane */
function findProximateAxes(ctx: KinemageContext, membrane: MembraneCandidate): Vec3[] {
const { numberOfSpherePoints, extent } = ctx;
const points = generateSpherePoints(ctx, 30000);
let j = 4;
let sphere_pts2: Vec3[] = [];
const s = 2 * extent / numberOfSpherePoints;
while (sphere_pts2.length < numberOfSpherePoints) {
const dsq = (s + j) * (s + j);
sphere_pts2 = [];
for (let i = 0, il = points.length; i < il; i++) {
if (v3squaredDistance(points[i], membrane.spherePoint!) < dsq) {
sphere_pts2.push(points[i]);
}
}
j += 0.2;
}
return sphere_pts2;
}
interface HphobHphil {
hphob: number,
hphil: number
}
namespace HphobHphil {
export function initial(ctx: KinemageContext): HphobHphil {
const { exposed, hydrophobic } = ctx;
let hphob = 0;
let hphil = 0;
for (let k = 0, kl = exposed.length; k < kl; k++) {
if (hydrophobic[k]) {
hphob++;
} else {
hphil++;
}
}
return { hphob, hphil };
}
const testPoint = v3zero();
export function sliced(ctx: KinemageContext, stepSize: number, spherePoint: Vec3, diam: Vec3, diamNorm: number): HphobHphil[] {
const { exposed, hydrophobic, structure } = ctx;
const { units, serialMapping } = structure;
const { unitIndices, elementIndices } = serialMapping;
const sliceStats: HphobHphil[] = [];
for (let i = 0, il = diamNorm - stepSize; i < il; i += stepSize) {
sliceStats[sliceStats.length] = { hphob: 0, hphil: 0 };
}
for (let i = 0, il = exposed.length; i < il; i++) {
const unit = units[unitIndices[exposed[i]]];
const elementIndex = elementIndices[exposed[i]];
v3set(testPoint, unit.conformation.x(elementIndex), unit.conformation.y(elementIndex), unit.conformation.z(elementIndex));
v3sub(testPoint, testPoint, spherePoint);
if (hydrophobic[i]) {
sliceStats[Math.floor(v3dot(testPoint, diam) / diamNorm / stepSize)].hphob++;
} else {
sliceStats[Math.floor(v3dot(testPoint, diam) / diamNorm / stepSize)].hphil++;
}
}
return sliceStats;
}
}
/** Returns true if the definition considers this as membrane-favoring amino acid */
export function isHydrophobic(definition: Set<string>, label_comp_id: string): boolean {
return definition.has(label_comp_id);
}
/** Accessible surface area used for normalization. ANVIL uses 'Total-Side REL' values from NACCESS, from: Hubbard, S. J., & Thornton, J. M. (1993). naccess. Computer Program, Department of Biochemistry and Molecular Biology, University College London, 2(1). */
export const MaxAsa: { [k: string]: number } = {
'ALA': 69.41,
'ARG': 201.25,
'ASN': 106.24,
'ASP': 102.69,
'CYS': 96.75,
'GLU': 134.74,
'GLN': 140.99,
'GLY': 32.33,
'HIS': 147.08,
'ILE': 137.96,
'LEU': 141.12,
'LYS': 163.30,
'MET': 156.64,
'PHE': 164.11,
'PRO': 119.90,
'SER': 78.11,
'THR': 101.70,
'TRP': 211.26,
'TYR': 177.38,
'VAL': 114.28
};
function setLocation(l: StructureElement.Location, structure: Structure, serialIndex: number) {
l.structure = structure;
l.unit = structure.units[structure.serialMapping.unitIndices[serialIndex]];
l.element = structure.serialMapping.elementIndices[serialIndex];
return l;
}

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { StructureRepresentationPresetProvider, PresetStructureRepresentations } from '../../mol-plugin-state/builder/structure/representation-preset';
import { KinemageDataProvider, KinemageData } from './prop';
import { StateObjectRef, StateTransformer, StateTransform } from '../../mol-state';
import { Task } from '../../mol-task';
import { PluginBehavior } from '../../mol-plugin/behavior';
import { KinemageDataRepresentationProvider, KinemageDataParams, KinemageDataRepresentation } from './representation';
import { HydrophobicityColorThemeProvider } from '../../mol-theme/color/hydrophobicity';
import { PluginStateObject, PluginStateTransform } from '../../mol-plugin-state/objects';
import { PluginContext } from '../../mol-plugin/context';
import { DefaultQueryRuntimeTable } from '../../mol-script/runtime/query/compiler';
import { StructureSelectionQuery, StructureSelectionCategory } from '../../mol-plugin-state/helpers/structure-selection-query';
import { MolScriptBuilder as MS } from '../../mol-script/language/builder';
import { GenericRepresentationRef } from '../../mol-plugin-state/manager/structure/hierarchy-state';
const Tag = KinemageData.Tag;
export const KinemageExtension = PluginBehavior.create<{ autoAttach: boolean }>({
name: 'kinemage-data-prop',
category: 'custom-props',
display: {
name: 'Kinemage data',
description: 'Data loaded from Kinemage.'
},
ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
private provider = KinemageDataProvider;
register(): void {
DefaultQueryRuntimeTable.addCustomProp(this.provider.descriptor);
this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
this.ctx.representation.structure.registry.add(KinemageDataRepresentationProvider);
this.ctx.query.structure.registry.add(isTransmembrane);
this.ctx.genericRepresentationControls.set(Tag.Representation, selection => {
const refs: GenericRepresentationRef[] = [];
selection.structures.forEach(structure => {
const memRepr = structure.genericRepresentations?.filter(r => r.cell.transform.transformer.id === KinemageData3D.id)[0];
if (memRepr) refs.push(memRepr);
});
return [refs, 'Membrane Orientation'];
});
this.ctx.builders.structure.representation.registerPreset(KinemageDataPreset);
}
update(p: { autoAttach: boolean }) {
const updated = this.params.autoAttach !== p.autoAttach;
this.params.autoAttach = p.autoAttach;
this.ctx.customStructureProperties.setDefaultAutoAttach(this.provider.descriptor.name, this.params.autoAttach);
return updated;
}
unregister() {
DefaultQueryRuntimeTable.removeCustomProp(this.provider.descriptor);
this.ctx.customStructureProperties.unregister(this.provider.descriptor.name);
this.ctx.representation.structure.registry.remove(KinemageDataRepresentationProvider);
this.ctx.query.structure.registry.remove(isTransmembrane);
this.ctx.genericRepresentationControls.delete(Tag.Representation);
this.ctx.builders.structure.representation.unregisterPreset(KinemageDataPreset);
}
},
params: () => ({
autoAttach: PD.Boolean(false)
})
});
//
export const isTransmembrane = StructureSelectionQuery('Residues Embedded in Membrane', MS.struct.modifier.union([
MS.struct.modifier.wholeResidues([
MS.struct.modifier.union([
MS.struct.generator.atomGroups({
'chain-test': MS.core.rel.eq([MS.ammp('objectPrimitive'), 'atomistic']),
'atom-test': KinemageData.symbols.isTransmembrane.symbol(),
})
])
])
]), {
description: 'Select residues that are embedded between the membrane layers.',
category: StructureSelectionCategory.Residue,
ensureCustomProperties: (ctx, structure) => {
return KinemageDataProvider.attach(ctx, structure);
}
});
//
export { KinemageData3D };
type KinemageData3D = typeof KinemageData3D
const KinemageData3D = PluginStateTransform.BuiltIn({
name: 'kinemage-3d',
display: {
name: 'Kinemage 3D Data',
description: '3D Data loaded from Kinemage.'
},
from: PluginStateObject.Molecule.Structure,
to: PluginStateObject.Shape.Representation3D,
params: (a) => {
return {
...KinemageDataParams,
};
}
})({
canAutoUpdate({ oldParams, newParams }) {
return true;
},
apply({ a, params }, plugin: PluginContext) {
return Task.create('Membrane Orientation', async ctx => {
await KinemageDataProvider.attach({ runtime: ctx, assetManager: plugin.managers.asset, errorContext: plugin.errorContext }, a.data);
const repr = KinemageDataRepresentation({ webgl: plugin.canvas3d?.webgl, ...plugin.representation.structure.themes }, () => KinemageDataParams);
await repr.createOrUpdate(params, a.data).runInContext(ctx);
return new PluginStateObject.Shape.Representation3D({ repr, sourceData: a.data }, { label: 'Membrane Orientation' });
});
},
update({ a, b, newParams }, plugin: PluginContext) {
return Task.create('Membrane Orientation', async ctx => {
await KinemageDataProvider.attach({ runtime: ctx, assetManager: plugin.managers.asset, errorContext: plugin.errorContext }, a.data);
const props = { ...b.data.repr.props, ...newParams };
await b.data.repr.createOrUpdate(props, a.data).runInContext(ctx);
b.data.sourceData = a.data;
return StateTransformer.UpdateResult.Updated;
});
},
isApplicable(a) {
return KinemageDataProvider.isApplicable(a.data);
}
});
export const KinemageDataPreset = StructureRepresentationPresetProvider({
id: 'preset-kinemage',
display: {
name: 'Kinemage data', group: 'Annotation',
description: 'Shows data loaded from Kinemage file.'
},
isApplicable(a) {
return KinemageDataProvider.isApplicable(a.data);
},
params: () => StructureRepresentationPresetProvider.CommonParams,
async apply(ref, params, plugin) {
const structureCell = StateObjectRef.resolveAndCheck(plugin.state.data, ref);
const structure = structureCell?.obj?.data;
if (!structureCell || !structure) return {};
if (!KinemageDataProvider.get(structure).value) {
await plugin.runTask(Task.create('Membrane Orientation', async runtime => {
await KinemageDataProvider.attach({ runtime, assetManager: plugin.managers.asset, errorContext: plugin.errorContext }, structure);
}));
}
const KinemageData = await tryCreateKinemageData(plugin, structureCell);
const colorTheme = HydrophobicityColorThemeProvider.name as any;
const preset = await PresetStructureRepresentations.auto.apply(ref, { ...params, theme: { globalName: colorTheme, focus: { name: colorTheme } } }, plugin);
return { components: preset.components, representations: { ...preset.representations, KinemageData } };
}
});
export function tryCreateKinemageData(plugin: PluginContext, structure: StateObjectRef<PluginStateObject.Molecule.Structure>, params?: StateTransformer.Params<KinemageData3D>, initialState?: Partial<StateTransform.State>) {
const state = plugin.state.data;
const KinemageData = state.build().to(structure)
.applyOrUpdateTagged('membrane-orientation-3d', KinemageData3D, params, { state: initialState });
return KinemageData.commit({ revertOnError: true });
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Structure, StructureProperties, Unit } from '../../mol-model/structure';
import { CustomPropertyDescriptor } from '../../mol-model/custom-property';
import { KinemageParams, KinemageProps, computeKinemage, isInMembranePlane } from './algorithm';
import { CustomStructureProperty } from '../../mol-model-props/common/custom-structure-property';
import { CustomProperty } from '../../mol-model-props/common/custom-property';
import { Vec3 } from '../../mol-math/linear-algebra';
import { QuerySymbolRuntime } from '../../mol-script/runtime/query/base';
import { CustomPropSymbol } from '../../mol-script/language/symbol';
import { Type } from '../../mol-script/language/type';
export const KinemageDataParams = {
...KinemageParams
};
export type KinemageDataParams = typeof KinemageDataParams
export type KinemageDataProps = PD.Values<KinemageDataParams>
export { KinemageData };
interface KinemageData {
// point in membrane boundary
readonly planePoint1: Vec3,
// point in opposite side of membrane boundary
readonly planePoint2: Vec3,
// normal vector of membrane layer
readonly normalVector: Vec3,
// the radius of the membrane layer
readonly radius: number,
readonly centroid: Vec3
}
namespace KinemageData {
export enum Tag {
Representation = 'membrane-orientation-3d'
}
const pos = Vec3();
export const symbols = {
isTransmembrane: QuerySymbolRuntime.Dynamic(CustomPropSymbol('computed', 'membrane-orientation.is-transmembrane', Type.Bool),
ctx => {
const { unit, structure } = ctx.element;
const { x, y, z } = StructureProperties.atom;
if (!Unit.isAtomic(unit)) return 0;
const KinemageData = KinemageDataProvider.get(structure).value;
if (!KinemageData) return 0;
Vec3.set(pos, x(ctx.element), y(ctx.element), z(ctx.element));
const { normalVector, planePoint1, planePoint2 } = KinemageData!;
return isInMembranePlane(pos, normalVector, planePoint1, planePoint2);
})
};
}
export const KinemageDataProvider: CustomStructureProperty.Provider<KinemageDataParams, KinemageData> = CustomStructureProperty.createProvider({
label: 'Membrane Orientation',
descriptor: CustomPropertyDescriptor({
name: 'Kinemage_computed_membrane_orientation',
symbols: KinemageData.symbols,
// TODO `cifExport`
}),
type: 'root',
defaultParams: KinemageDataParams,
getParams: (data: Structure) => KinemageDataParams,
isApplicable,
obtain: async (ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageDataProps>) => {
const p = { ...PD.getDefaultValues(KinemageDataParams), ...props };
try {
return { value: await computeKinemageProps(ctx, data, p) };
} catch (e) {
// the "Residues Embedded in Membrane" symbol may bypass isApplicable() checks
console.warn('Failed to predict membrane orientation. This happens for short peptides and entries without amino acids.');
return { value: undefined };
}
}
});
function isApplicable(structure: Structure) {
if (!structure.isAtomic) return false;
for (const model of structure.models) {
const { byEntityKey } = model.sequence;
for (const key of Object.keys(byEntityKey)) {
const { kind, length } = byEntityKey[+key].sequence;
if (kind !== 'protein') continue; // can only process protein chains
if (length >= 15) return true; // short peptides might fail
}
}
return false;
}
async function computeKinemageProps(ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageProps>): Promise<KinemageData> {
const p = { ...PD.getDefaultValues(KinemageParams), ...props };
return await computeKinemage(data, p).runInContext(ctx.runtime);
}

View File

@@ -0,0 +1,151 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Vec3, Mat4 } from '../../mol-math/linear-algebra';
import { Representation, RepresentationContext, RepresentationParamsGetter } from '../../mol-repr/representation';
import { Structure } from '../../mol-model/structure';
import { StructureRepresentationProvider, StructureRepresentation, StructureRepresentationStateBuilder } from '../../mol-repr/structure/representation';
import { KinemageData } from './prop';
import { ThemeRegistryContext } from '../../mol-theme/theme';
import { ShapeRepresentation } from '../../mol-repr/shape/representation';
import { Shape } from '../../mol-model/shape';
import { RuntimeContext } from '../../mol-task';
import { Lines } from '../../mol-geo/geometry/lines/lines';
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
import { LinesBuilder } from '../../mol-geo/geometry/lines/lines-builder';
import { Circle } from '../../mol-geo/primitive/circle';
import { transformPrimitive } from '../../mol-geo/primitive/primitive';
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { KinemageDataProvider } from './prop';
import { MarkerActions } from '../../mol-util/marker-action';
import { lociLabel } from '../../mol-theme/label';
import { ColorNames } from '../../mol-util/color/names';
import { CustomProperty } from '../../mol-model-props/common/custom-property';
const SharedParams = {
color: PD.Color(ColorNames.lightgrey),
radiusFactor: PD.Numeric(1.2, { min: 0.1, max: 3.0, step: 0.01 }, { description: 'Scale the radius of the membrane layer' })
};
const BilayerPlanesParams = {
...Mesh.Params,
...SharedParams,
sectorOpacity: PD.Numeric(0.5, { min: 0, max: 1, step: 0.01 }),
};
export type BilayerPlanesParams = typeof BilayerPlanesParams
export type BilayerPlanesProps = PD.Values<BilayerPlanesParams>
const BilayerRimsParams = {
...Lines.Params,
...SharedParams,
lineSizeAttenuation: PD.Boolean(false),
linesSize: PD.Numeric(0.5, { min: 0.01, max: 50, step: 0.01 }),
dashedLines: PD.Boolean(false),
};
export type BilayerRimsParams = typeof BilayerRimsParams
export type BilayerRimsProps = PD.Values<BilayerRimsParams>
const KinemageDataVisuals = {
'bilayer-planes': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<KinemageData, BilayerPlanesParams>) => ShapeRepresentation(getBilayerPlanes, Mesh.Utils, { modifyState: s => ({ ...s, markerActions: MarkerActions.Highlighting }), modifyProps: p => ({ ...p, alpha: p.sectorOpacity, ignoreLight: true, doubleSided: false }) }),
'bilayer-rims': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<KinemageData, BilayerRimsParams>) => ShapeRepresentation(getBilayerRims, Lines.Utils, { modifyState: s => ({ ...s, markerActions: MarkerActions.Highlighting }) })
};
export const KinemageDataParams = {
...BilayerPlanesParams,
...BilayerRimsParams,
visuals: PD.MultiSelect(['bilayer-planes', 'bilayer-rims'], PD.objectToOptions(KinemageDataVisuals)),
};
export type KinemageDataParams = typeof KinemageDataParams
export type KinemageDataProps = PD.Values<KinemageDataParams>
export function getKinemageDataParams(ctx: ThemeRegistryContext, structure: Structure) {
return PD.clone(KinemageDataParams);
}
export type KinemageDataRepresentation = StructureRepresentation<KinemageDataParams>
export function KinemageDataRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, KinemageDataParams>): KinemageDataRepresentation {
return Representation.createMulti('Membrane Orientation', ctx, getParams, StructureRepresentationStateBuilder, KinemageDataVisuals as unknown as Representation.Def<Structure, KinemageDataParams>);
}
export const KinemageDataRepresentationProvider = StructureRepresentationProvider({
name: 'membrane-orientation',
label: 'Membrane Orientation',
description: 'Displays a grid of points representing membrane layers.',
factory: KinemageDataRepresentation,
getParams: getKinemageDataParams,
defaultValues: PD.getDefaultValues(KinemageDataParams),
defaultColorTheme: { name: 'shape-group' },
defaultSizeTheme: { name: 'shape-group' },
isApplicable(structure: Structure) { return KinemageDataProvider.isApplicable(structure); },
ensureCustomProperties: {
attach: (ctx: CustomProperty.Context, structure: Structure) => KinemageDataProvider.attach(ctx, structure, void 0, true),
detach: (data) => KinemageDataProvider.ref(data, false)
}
});
function membraneLabel(data: Structure) {
return `${lociLabel(Structure.Loci(data))} | Membrane Orientation`;
}
function getBilayerRims(ctx: RuntimeContext, data: Structure, props: BilayerRimsProps, shape?: Shape<Lines>): Shape<Lines> {
const { planePoint1: p1, planePoint2: p2, centroid, radius } = KinemageDataProvider.get(data).value!;
const scaledRadius = props.radiusFactor * radius;
const builder = LinesBuilder.create(128, 64, shape?.geometry);
getLayerCircle(builder, p1, centroid, scaledRadius, props);
getLayerCircle(builder, p2, centroid, scaledRadius, props);
return Shape.create('Bilayer rims', data, builder.getLines(), () => props.color, () => props.linesSize, () => membraneLabel(data));
}
function getLayerCircle(builder: LinesBuilder, p: Vec3, centroid: Vec3, radius: number, props: BilayerRimsProps, shape?: Shape<Lines>) {
const circle = getCircle(p, centroid, radius);
const { indices, vertices } = circle;
for (let j = 0, jl = indices.length; j < jl; j += 3) {
if (props.dashedLines && j % 2 === 1) continue; // draw every other segment to get dashes
const start = indices[j] * 3;
const end = indices[j + 1] * 3;
const startX = vertices[start];
const startY = vertices[start + 1];
const startZ = vertices[start + 2];
const endX = vertices[end];
const endY = vertices[end + 1];
const endZ = vertices[end + 2];
builder.add(startX, startY, startZ, endX, endY, endZ, 0);
}
}
const tmpMat = Mat4();
const tmpV = Vec3();
function getCircle(p: Vec3, centroid: Vec3, radius: number) {
if (Vec3.dot(Vec3.unitY, Vec3.sub(tmpV, p, centroid)) === 0) {
Mat4.targetTo(tmpMat, p, centroid, Vec3.unitY);
} else {
Mat4.targetTo(tmpMat, p, centroid, Vec3.unitX);
}
Mat4.setTranslation(tmpMat, p);
Mat4.mul(tmpMat, tmpMat, Mat4.rotX90);
const circle = Circle({ radius, segments: 64 });
return transformPrimitive(circle, tmpMat);
}
function getBilayerPlanes(ctx: RuntimeContext, data: Structure, props: BilayerPlanesProps, shape?: Shape<Mesh>): Shape<Mesh> {
const { planePoint1: p1, planePoint2: p2, centroid, radius } = KinemageDataProvider.get(data).value!;
const state = MeshBuilder.createState(128, 64, shape && shape.geometry);
const scaledRadius = props.radiusFactor * radius;
getLayerPlane(state, p1, centroid, scaledRadius);
getLayerPlane(state, p2, centroid, scaledRadius);
return Shape.create('Bilayer planes', data, MeshBuilder.getMesh(state), () => props.color, () => 1, () => membraneLabel(data));
}
function getLayerPlane(state: MeshBuilder.State, p: Vec3, centroid: Vec3, radius: number) {
const circle = getCircle(p, centroid, radius);
state.currentGroup = 0;
MeshBuilder.addPrimitive(state, Mat4.id, circle);
MeshBuilder.addPrimitiveFlipped(state, Mat4.id, circle);
}