|
| 1 | +/** |
| 2 | + * The reverse field: what the ligand SITS IN, not what it emits. |
| 3 | + * |
| 4 | + * Every field in this panel until now answered "what does my molecule look |
| 5 | + * like electrostatically?" — a question a chemist can mostly answer from the |
| 6 | + * 2D structure. The question they cannot answer, and open a 3D viewer for, is |
| 7 | + * the complementarity one: is the pocket positive where my ligand is negative? |
| 8 | + * |
| 9 | + * Two independent reviewers converged on the same architectural move, and it |
| 10 | + * is what makes this expressible: SOURCE (the atoms generating the field) and |
| 11 | + * FRAME (the box it is sampled in) are separate. Here SOURCE is the residue |
| 12 | + * shell and FRAME is the ligand's own box, so the grid stays ligand-sized |
| 13 | + * while the source can be the whole pocket. |
| 14 | + * |
| 15 | + * NO TOPOLOGY IS PERCEIVED. The classical field is exactly linear in the atom |
| 16 | + * set (measured, 5.2e-16 relative), so this needs (element, position, residue |
| 17 | + * identity) and nothing else — no bonds, no capping, no valence, no molfile. |
| 18 | + * That is not an optimisation: the app's molfile builder cannot express more |
| 19 | + * than one residue without silently dropping bonds, and this path structurally |
| 20 | + * cannot reach that defect because it never asks the question. |
| 21 | + */ |
| 22 | + |
| 23 | +import { PluginContext } from '../../../mol-plugin/context'; |
| 24 | +import { StructureElement, StructureProperties, Unit } from '../../../mol-model/structure'; |
| 25 | +import { Vec3 } from '../../../mol-math/linear-algebra'; |
| 26 | +import { OrderedSet } from '../../../mol-data/int'; |
| 27 | +import { resolveFocus } from '../../../chemistry.backend.perception.rdkit-wasm.editable/semantic-focus'; |
| 28 | +import type { LigandFocusOptions } from '../../../chemistry.backend.perception.rdkit-wasm.editable/semantic-focus'; |
| 29 | + |
| 30 | +export interface RegionSource { |
| 31 | + element: string; |
| 32 | + x: number; y: number; z: number; |
| 33 | + resname: string; |
| 34 | + atom_name: string; |
| 35 | +} |
| 36 | + |
| 37 | +export interface RegionRequest { |
| 38 | + sources: RegionSource[]; |
| 39 | + frame: { lo: [number, number, number], hi: [number, number, number], spacing: number }; |
| 40 | + ligandAtoms: number; |
| 41 | +} |
| 42 | + |
| 43 | +/** Atom records for every atom in a loci, carrying the residue identity the |
| 44 | + * backend needs to look a template charge up. Positions are scene coordinates, |
| 45 | + * so the cube comes back already registered — no alignment step exists. */ |
| 46 | +function lociAtoms(loci: StructureElement.Loci): RegionSource[] { |
| 47 | + const out: RegionSource[] = []; |
| 48 | + const position = Vec3(); |
| 49 | + const location = StructureElement.Location.create(loci.structure); |
| 50 | + for (const e of loci.elements) { |
| 51 | + if (!Unit.isAtomic(e.unit)) continue; |
| 52 | + const count = OrderedSet.size(e.indices); |
| 53 | + for (let i = 0; i < count; i++) { |
| 54 | + location.unit = e.unit; |
| 55 | + location.element = e.unit.elements[OrderedSet.getAt(e.indices, i)]; |
| 56 | + e.unit.conformation.position(location.element, position); |
| 57 | + out.push({ |
| 58 | + element: StructureProperties.atom.type_symbol(location), |
| 59 | + x: position[0], y: position[1], z: position[2], |
| 60 | + resname: StructureProperties.residue.label_comp_id(location), |
| 61 | + atom_name: StructureProperties.atom.label_atom_id(location), |
| 62 | + }); |
| 63 | + } |
| 64 | + } |
| 65 | + return out; |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * SOURCE = the residue shell around the ligand. FRAME = the ligand's box. |
| 70 | + * |
| 71 | + * The shell is the one `resolveFocus` already computes for the semantic layers |
| 72 | + * — whole residues within the cutoff, ligand subtracted — so this control |
| 73 | + * obeys the 3–8 Å slider that is already on screen instead of introducing a |
| 74 | + * second notion of "nearby" that could disagree with what is drawn. |
| 75 | + */ |
| 76 | +export function buildSurroundingsRequest( |
| 77 | + plugin: PluginContext, options: LigandFocusOptions, padding = 3.0, |
| 78 | +): RegionRequest | { error: string } { |
| 79 | + const structure = plugin.managers.structure.component.pivotStructure?.cell.obj?.data; |
| 80 | + if (!structure) return { error: 'no structure loaded' }; |
| 81 | + |
| 82 | + const { ligand, neighbourhood } = resolveFocus(structure, options); |
| 83 | + if (StructureElement.Loci.isEmpty(ligand)) { |
| 84 | + return { error: 'no deposited ligand — the reverse field needs something to sit IN' }; |
| 85 | + } |
| 86 | + if (StructureElement.Loci.isEmpty(neighbourhood)) { |
| 87 | + return { error: 'no residues within the cutoff — widen the ligand-focus radius' }; |
| 88 | + } |
| 89 | + |
| 90 | + const sources = lociAtoms(neighbourhood); |
| 91 | + const ligandAtoms = lociAtoms(ligand); |
| 92 | + if (sources.length === 0) return { error: 'the shell resolved to zero atoms' }; |
| 93 | + |
| 94 | + // FRAME from the LIGAND, deliberately. Sizing it to the source would make |
| 95 | + // the grid pocket-sized and the cube enormous for no gain: the answer being |
| 96 | + // asked for is what the field looks like where the ligand is. |
| 97 | + const lo: [number, number, number] = [Infinity, Infinity, Infinity]; |
| 98 | + const hi: [number, number, number] = [-Infinity, -Infinity, -Infinity]; |
| 99 | + for (const a of ligandAtoms) { |
| 100 | + lo[0] = Math.min(lo[0], a.x - padding); hi[0] = Math.max(hi[0], a.x + padding); |
| 101 | + lo[1] = Math.min(lo[1], a.y - padding); hi[1] = Math.max(hi[1], a.y + padding); |
| 102 | + lo[2] = Math.min(lo[2], a.z - padding); hi[2] = Math.max(hi[2], a.z + padding); |
| 103 | + } |
| 104 | + |
| 105 | + return { sources, frame: { lo, hi, spacing: 0.4 }, ligandAtoms: ligandAtoms.length }; |
| 106 | +} |
0 commit comments