Skip to content

Commit d402b2a

Browse files
committed
[feat.fields.sigma.D7+] the reverse field is online — the pocket as source, the ligand only supplies the box
Ivan: 反向场…不是都已经做完了吗为什么没有上线呢. Built, measured, and unreachable. Now there is a button. WHAT IT ANSWERS, and why it is a different question from the five above it: every field in this panel so far described what the ligand EMITS, which a chemist can mostly read off the 2D structure. The one they open a 3D viewer for is complementarity — is the pocket positive where my ligand is negative. That needs the field the ligand SITS IN. SOURCE = the residue shell. FRAME = the ligand's own box. Separating them is what makes it affordable: the grid stays ligand-sized while the source is the whole pocket. Sizing the frame to the source would produce a pocket-sized cube for no gain, because the question is what the field looks like WHERE THE LIGAND IS. NO TOPOLOGY IS PERCEIVED, and that is structural rather than an optimisation. The classical field is exactly linear in the atom set (5.2e-16 measured), so this path needs element, position and residue identity — no bonds, no capping, no valence, no molfile. The app's molfile builder cannot express more than one residue without silently dropping bonds, and this path CANNOT REACH that defect because it never asks the question. The bug is still there and still needs fixing for the QM routes; the reverse field simply does not depend on it. Reuse, not reimplementation. The shell is the one resolveFocus already computes for the semantic layers — whole residues within the cutoff, ligand subtracted — so the button obeys the 3-8 A slider already on screen. Exporting that function was one word; copying its fifteen lines into the facet would have been one fact in two homes, and the two would have drifted the first time the cutoff policy changed. The lab passes its focus options in, so the shell that gets charged is the shell the user can see. Same units, same fixed contour and same colours as `mep`, deliberately: the entire value of this field is comparing it against the ligand's own, and a different scale would make that comparison a lie. The button is full-width and last, because it is not a sixth property of the ligand — it is the other direction.
1 parent 26aac13 commit d402b2a

5 files changed

Lines changed: 174 additions & 4 deletions

File tree

‎src/app.frontend.facets.molstar-rdkit.editable/facets/field-wells/index.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createVolumeRepresentationParams } from '../../../mol-plugin-state/help
1919
import { Grid, Volume } from '../../../mol-model/volume';
2020
import { Color } from '../../../mol-util/color';
2121
import { focusSphereKeepingSlab } from '../../camera-slab';
22+
import { buildSurroundingsRequest } from './surroundings';
2223

2324
// Follow the page's host: the daemon runs beside whatever served the app, so
2425
// a hardcoded 127.0.0.1 would point a Mac's browser at the Mac itself and
@@ -103,7 +104,9 @@ function tokenColor(name: string, fallback: number): number {
103104
}
104105
const shellRef = (sign: 'pos' | 'neg', i: number) => `field-wells-repr-${sign}-${i}`;
105106

106-
export type FieldKind = 'mep' | 'mep_qm' | 'homo' | 'lumo' | 'density' | 'mlp';
107+
export type FieldKind = 'mep' | 'mep_qm' | 'homo' | 'lumo' | 'density' | 'mlp'
108+
/** The REVERSE field: the pocket as source, read in the ligand's box. */
109+
| 'pocket_mep';
107110

108111
interface KindSpec {
109112
label: string;
@@ -141,6 +144,10 @@ const Kinds: Record<FieldKind, KindSpec> = {
141144
density: { label: 'e⁻ density', iso: 0.05, cubeScale: 1, diverging: false, unit: 'e/Bohr³', posToken: '--viz-density', negToken: '--viz-density', posColor: 0xd8aa75, negColor: 0xd8aa75, quantum: true },
142145
// Default iso must sit BELOW the hydrophilic side's typical |min| (~0.06
143146
// on aspirin) or the cyan lobes never exist and the field looks all-grease.
147+
// Same units, same fixed contour and same colours as `mep` on purpose: the
148+
// whole value of the reverse field is comparing it against the ligand's
149+
// own, and a different scale would make that comparison a lie.
150+
pocket_mep: { label: 'Pocket field (what the ligand sits in)', iso: 10, cubeScale: 1, diverging: true, unit: 'kcal/mol', posToken: '--viz-mep-pos', negToken: '--viz-mep-neg', posColor: 0x6788bc, negColor: 0xbd777b, quantum: false },
144151
mlp: { label: 'Lipophilicity', iso: 0.25, cubeScale: 1, diverging: true, unit: 'MLP', posToken: '--viz-mlp-pos', negToken: '--viz-mlp-neg', posColor: 0xd5b979, negColor: 0x74ccdd, quantum: false },
145152
};
146153

@@ -649,6 +656,45 @@ async function updateIsoSurfaces() {
649656
updateIsoReadout();
650657
}
651658

659+
/**
660+
* The reverse field. Goes to /field/region rather than /field, because it is
661+
* not a field OF the ligand: the ligand only supplies the box.
662+
*/
663+
async function requestPocketField() {
664+
if (!plugin || busy) return;
665+
clearRetry();
666+
const built = buildSurroundingsRequest(plugin, currentFocusOptions?.() ?? {});
667+
if ('error' in built) { setStatus(built.error, 'error'); return; }
668+
busy = true;
669+
setButtonsEnabled();
670+
setStatus(`Pocket field — ${built.sources.length} shell atoms as source, `
671+
+ `sampled in the ligand's box…`, 'busy');
672+
try {
673+
const resp = await fetch(`${BACKEND}/field/region`, {
674+
method: 'POST', headers: { 'Content-Type': 'application/json' },
675+
body: JSON.stringify({ sources: built.sources, frame: built.frame, kind: 'mep' }),
676+
});
677+
const payload = await resp.json();
678+
if (!payload.ok) { setStatus(payload.error, 'error'); return; }
679+
await renderCube(payload.cube, 'pocket_mep', payload.meta);
680+
renderMeta(payload.meta);
681+
setStatus(`Pocket field from ${payload.meta.n_sources_used} residue-shell `
682+
+ `atoms · ${payload.meta.charge_model}.`, 'ok');
683+
} catch (e) {
684+
setStatus(`Backend unreachable — ${e instanceof Error ? e.message : String(e)}`, 'error');
685+
} finally {
686+
busy = false;
687+
setButtonsEnabled();
688+
}
689+
}
690+
691+
/** Supplied by the lab so the shell obeys the SAME cutoff slider the semantic
692+
* layers use, rather than this facet inventing a second notion of "nearby". */
693+
let currentFocusOptions: (() => Record<string, unknown>) | null = null;
694+
export function setFieldFocusOptionsProvider(fn: () => Record<string, unknown>) {
695+
currentFocusOptions = fn;
696+
}
697+
652698
async function requestField(kind: FieldKind, budget = budgetFor(kind)) {
653699
if (!plugin || !molfile || busy) return;
654700
const spec = Kinds[kind];
@@ -761,6 +807,7 @@ export function initFieldWellsPanel(p: PluginContext) {
761807
document.querySelectorAll<HTMLButtonElement>('.field-btn').forEach(btn => {
762808
btn.addEventListener('click', () => {
763809
const kind = btn.dataset.field as FieldKind;
810+
if (kind === 'pocket_mep') { void requestPocketField(); return; }
764811
if (Kinds[kind]) void requestField(kind);
765812
});
766813
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
}

‎src/app.frontend.facets.molstar-rdkit.editable/index.html‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@
194194
.import-status[data-tone="ok"] { color: var(--accent); }
195195

196196
/* Fields panel — energy wells */
197+
.field-btn-wide { grid-column: 1 / -1; }
197198
.field-buttons {
198199
display: grid;
199200
grid-template-columns: repeat(2, 1fr);
@@ -1411,6 +1412,10 @@ <h2>Energy field wells</h2>
14111412
<button type="button" class="field-btn" data-field="lumo"><i class="field-sym">φ*</i> LUMO</button>
14121413
<button type="button" class="field-btn" data-field="density"><i class="field-sym">ρ</i> e⁻ density</button>
14131414
<button type="button" class="field-btn" data-field="mlp"><i class="field-sym">logP</i> Lipophilicity</button>
1415+
<!-- The REVERSE field. Deliberately last and full-width: it is a
1416+
different question from the five above it — those are
1417+
properties OF the ligand, this is the field it sits IN. -->
1418+
<button type="button" class="field-btn field-btn-wide" data-field="pocket_mep"><i class="field-sym">V↺</i> Pocket field · what the ligand sits in</button>
14141419
<button type="button" class="field-btn" id="field-clear">✕ Clear</button>
14151420
<!-- Deliberately NOT .field-btn: that class is
14161421
disabled en masse while a field is solving, and

‎src/app.frontend.facets.molstar-rdkit.editable/index.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import { LigandDepiction, type AtomHighlight, type AtomPosition } from '../chemi
7373
import { ChemistryCache } from '../chemistry.backend.perception.rdkit-wasm.editable/chemistry-cache';
7474
import { ligandLociToMolfile, lociFromFocusOptions } from '../chemistry.backend.perception.rdkit-wasm.editable/ligand-pipeline';
7575
import { renderPropertiesPanel } from './facets/property-cockpit';
76-
import { initFieldWellsPanel, updateFieldWellsLigand, autoRenderElectrostaticWell } from './facets/field-wells';
76+
import { initFieldWellsPanel, updateFieldWellsLigand, autoRenderElectrostaticWell, setFieldFocusOptionsProvider } from './facets/field-wells';
7777
import { initLigandPhysicsPanel, updateLigandPhysics } from './facets/ligand-physics';
7878
import { initPharmacophoreDesigner, updatePharmacophoreDesigner } from './facets/pharmacophore-designer';
7979
import { initBondAtlas, updateBondAtlas } from './facets/bond-atlas';
@@ -399,7 +399,13 @@ class MolecularVfxLab {
399399
this.workbench.plugin.behaviors.interaction.click.subscribe(({ current }) => this.handleInteractionClick(current.loci));
400400
// Other agents' facets — wrap in try/catch so their failures don't
401401
// crash the core lab init and make the UI disappear.
402-
try { initFieldWellsPanel(this.workbench.plugin); } catch (e) { console.error('[fields] init failed:', e); }
402+
try {
403+
initFieldWellsPanel(this.workbench.plugin);
404+
// The pocket field must use the SAME cutoff the semantic layers and
405+
// the 3-8 A slider already use, or the shell it charges is not the
406+
// shell the user can see on screen.
407+
setFieldFocusOptionsProvider(() => this.currentFocusOptions() as unknown as Record<string, unknown>);
408+
} catch (e) { console.error('[fields] init failed:', e); }
403409
try { initLigandPhysicsPanel(this.workbench.plugin); } catch (e) { console.error('[physics] init failed:', e); }
404410
try { initPharmacophoreDesigner(this.workbench.plugin); } catch (e) { console.error('[designer] init failed:', e); }
405411
try { initBondAtlas(); } catch (e) { console.error('[bond-atlas] init failed:', e); }

‎src/chemistry.backend.perception.rdkit-wasm.editable/semantic-focus.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,13 @@ function depositedLigandLoci(structure: Structure) {
162162
return StructureSelection.toLociWithCurrentUnits(selection);
163163
}
164164

165-
function resolveFocus(structure: Structure, options: LigandFocusOptions): ResolvedFocus {
165+
/**
166+
* Exported so the field facets can use the SAME neighbourhood the semantic
167+
* layers already draw and the 3–8 Å slider already controls. Duplicating this
168+
* into a facet would be one fact in two homes, and the two would drift the
169+
* first time the cutoff policy changed.
170+
*/
171+
export function resolveFocus(structure: Structure, options: LigandFocusOptions): ResolvedFocus {
166172
const cutoff = Math.min(8, Math.max(3, options.cutoff ?? 5));
167173
const ligand = options.target && options.target.hash === structure.hashCode
168174
? StructureElement.Bundle.toLoci(options.target, structure)

0 commit comments

Comments
 (0)