vimol 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vimol/__init__.py +63 -0
- vimol/__main__.py +4 -0
- vimol/app.py +218 -0
- vimol/arrows.py +91 -0
- vimol/bonds.py +79 -0
- vimol/camera.py +77 -0
- vimol/editor.py +646 -0
- vimol/elements.py +123 -0
- vimol/gl_adapter.py +114 -0
- vimol/gl_render.py +742 -0
- vimol/input.py +252 -0
- vimol/kitty.py +297 -0
- vimol/molecule.py +159 -0
- vimol/parsers/__init__.py +88 -0
- vimol/parsers/mol.py +57 -0
- vimol/parsers/pdb.py +97 -0
- vimol/parsers/xyz.py +68 -0
- vimol/periodic_table.py +97 -0
- vimol/py.typed +0 -0
- vimol/render.py +600 -0
- vimol/scene.py +239 -0
- vimol/templates.py +205 -0
- vimol/viewer.py +1159 -0
- vimol/widget.py +611 -0
- vimol-0.1.0.dist-info/METADATA +302 -0
- vimol-0.1.0.dist-info/RECORD +30 -0
- vimol-0.1.0.dist-info/WHEEL +5 -0
- vimol-0.1.0.dist-info/entry_points.txt +2 -0
- vimol-0.1.0.dist-info/licenses/LICENSE +20 -0
- vimol-0.1.0.dist-info/top_level.txt +1 -0
vimol/editor.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""In-place structure editing: grow fragments and birth new molecules.
|
|
2
|
+
|
|
3
|
+
These operations mutate a :class:`~vimol.molecule.Molecule` and re-perceive
|
|
4
|
+
its bonds. Geometry (which directions to place neighbors in) comes from
|
|
5
|
+
:mod:`vimol.templates`; bond *lengths* are the sum of covalent radii, matching
|
|
6
|
+
how :mod:`vimol.bonds` perceives connectivity.
|
|
7
|
+
|
|
8
|
+
The public operations mirror the interactive gestures:
|
|
9
|
+
|
|
10
|
+
* :func:`birth_molecule` -- click empty space -> a fresh capped atom (methane).
|
|
11
|
+
* :func:`grow_at_atom` -- click an atom -> edit the structure there.
|
|
12
|
+
* :func:`replace_atom` -- the heavy-atom half of ``grow_at_atom``.
|
|
13
|
+
|
|
14
|
+
``grow_at_atom`` splits on what was clicked: a hydrogen is *promoted* to the
|
|
15
|
+
building element and its freed valences capped ("click an H, it becomes a
|
|
16
|
+
carbon with three new hydrogens"); a heavier atom is *replaced* in place by
|
|
17
|
+
the building element, snapping its terminal hydrogens and topping up its
|
|
18
|
+
valency with new ones.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .molecule import Molecule
|
|
28
|
+
from .bonds import perceive_bonds
|
|
29
|
+
from . import elements
|
|
30
|
+
from . import templates
|
|
31
|
+
from .templates import AtomTemplate
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bond_length(a: str, b: str) -> float:
|
|
35
|
+
return elements.covalent_radius(a) + elements.covalent_radius(b)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _neighbors(mol: Molecule, idx: int) -> List[int]:
|
|
39
|
+
out = []
|
|
40
|
+
for i, j, _order in mol.bonds:
|
|
41
|
+
if i == idx:
|
|
42
|
+
out.append(j)
|
|
43
|
+
elif j == idx:
|
|
44
|
+
out.append(i)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _reperceive(mol: Molecule) -> None:
|
|
49
|
+
"""Recompute distance-based bonds after a geometry change.
|
|
50
|
+
|
|
51
|
+
Every edit ends here, which would otherwise silently erase manual bonds
|
|
52
|
+
(see :func:`add_manual_bond`) -- so the distance-perceived list is unioned
|
|
53
|
+
with ``mol.manual_bonds``, deduped on the normalized ``(i, j)`` pair, so a
|
|
54
|
+
manual bond that also happens to be within auto range is never listed
|
|
55
|
+
twice.
|
|
56
|
+
"""
|
|
57
|
+
bonds = perceive_bonds(mol)
|
|
58
|
+
seen = {(i, j) for i, j, _order in bonds}
|
|
59
|
+
for i, j, order in mol.manual_bonds:
|
|
60
|
+
if (i, j) not in seen:
|
|
61
|
+
bonds.append((i, j, order))
|
|
62
|
+
seen.add((i, j))
|
|
63
|
+
mol.bonds = bonds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _record_intended_bond(mol: Molecule, i: int, j: int, order: int = 1) -> bool:
|
|
67
|
+
"""Append a deliberate bond to ``mol.manual_bonds`` without re-perceiving.
|
|
68
|
+
|
|
69
|
+
Normalizes to ``i < j`` and dedupes; returns False (no change) when
|
|
70
|
+
``i == j`` or the pair is already recorded. Every bond the editor
|
|
71
|
+
*intends* -- the ones it creates on purpose, as opposed to what distance
|
|
72
|
+
perception happens to find -- goes through here, so cleanup can tell
|
|
73
|
+
intended connectivity from proximity accidents. Callers are responsible
|
|
74
|
+
for the (single) :func:`_reperceive` that unions these into ``mol.bonds``.
|
|
75
|
+
"""
|
|
76
|
+
if i == j:
|
|
77
|
+
return False
|
|
78
|
+
if i > j:
|
|
79
|
+
i, j = j, i
|
|
80
|
+
if any(a == i and b == j for a, b, _order in mol.manual_bonds):
|
|
81
|
+
return False
|
|
82
|
+
mol.manual_bonds.append((i, j, order))
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def add_manual_bond(mol: Molecule, i: int, j: int, order: int = 1) -> bool:
|
|
87
|
+
"""Record an explicit bond between *i* and *j* that survives re-perception.
|
|
88
|
+
|
|
89
|
+
Normalizes to ``i < j``. Returns False (no change) if ``i == j`` or the
|
|
90
|
+
pair is already in ``mol.manual_bonds``; otherwise appends and
|
|
91
|
+
re-perceives (which unions it into ``mol.bonds``) and returns True. If
|
|
92
|
+
the pair is already within the auto-perception distance, the manual bond
|
|
93
|
+
is still recorded -- that is what makes the drawn bond robust to later
|
|
94
|
+
geometry changes that pull the atoms apart.
|
|
95
|
+
"""
|
|
96
|
+
if not _record_intended_bond(mol, i, j, order):
|
|
97
|
+
return False
|
|
98
|
+
_reperceive(mol)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def measurement(mol: Molecule, sel: Sequence[int]) -> str:
|
|
103
|
+
"""Render a live distance/angle/dihedral readout for an ordered pick list.
|
|
104
|
+
|
|
105
|
+
Pure and side-effect-free (no terminal, no widget state) so it is
|
|
106
|
+
testable on its own; the widget only owns *which* atoms are selected
|
|
107
|
+
(``measure_sel``), this just formats what they mean:
|
|
108
|
+
|
|
109
|
+
* < 2 atoms -> "" (nothing to report yet).
|
|
110
|
+
* 2 atoms -> euclidean distance, 3 decimals.
|
|
111
|
+
* 3 atoms -> the angle at the middle atom between it and the other two,
|
|
112
|
+
1 decimal.
|
|
113
|
+
* 4 atoms -> the standard (signed) dihedral about the middle bond -- the
|
|
114
|
+
angle between the (i, j, k) and (j, k, l) planes, 1 decimal. The sign
|
|
115
|
+
convention is whatever ``atan2`` gives; callers that care about an
|
|
116
|
+
absolute value should take one themselves.
|
|
117
|
+
|
|
118
|
+
Extra picks beyond the first four are ignored (the widget itself never
|
|
119
|
+
lets the live selection grow past 4 -- see ``MoleculeWidget._measure_click``
|
|
120
|
+
-- but this stays defensive rather than raising on a longer sequence).
|
|
121
|
+
Indices are rendered with the same ``#idx`` convention as the hover
|
|
122
|
+
readout (:meth:`MoleculeWidget.atom_info`).
|
|
123
|
+
"""
|
|
124
|
+
n = len(sel)
|
|
125
|
+
if n < 2:
|
|
126
|
+
return ""
|
|
127
|
+
pos = mol.positions
|
|
128
|
+
if n == 2:
|
|
129
|
+
i, j = sel[0], sel[1]
|
|
130
|
+
d = float(np.linalg.norm(pos[i] - pos[j]))
|
|
131
|
+
return f"d(#{i}–#{j}) = {d:.3f} Å"
|
|
132
|
+
if n == 3:
|
|
133
|
+
i, j, k = sel[0], sel[1], sel[2]
|
|
134
|
+
v1 = pos[i] - pos[j]
|
|
135
|
+
v2 = pos[k] - pos[j]
|
|
136
|
+
cos_a = float(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)))
|
|
137
|
+
angle = np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0)))
|
|
138
|
+
return f"∠(#{i}–#{j}–#{k}) = {angle:.1f}°"
|
|
139
|
+
i, j, k, l = sel[0], sel[1], sel[2], sel[3]
|
|
140
|
+
b1 = pos[j] - pos[i]
|
|
141
|
+
b2 = pos[k] - pos[j]
|
|
142
|
+
b3 = pos[l] - pos[k]
|
|
143
|
+
n1 = np.cross(b1, b2)
|
|
144
|
+
n2 = np.cross(b2, b3)
|
|
145
|
+
m1 = np.cross(n1, b2 / np.linalg.norm(b2))
|
|
146
|
+
x = float(np.dot(n1, n2))
|
|
147
|
+
y = float(np.dot(m1, n2))
|
|
148
|
+
phi = np.degrees(np.arctan2(y, x))
|
|
149
|
+
return f"φ(#{i}–#{j}–#{k}–#{l}) = {phi:.1f}°"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cap(mol: Molecule, center_idx: int, center: np.ndarray, dirs: np.ndarray,
|
|
153
|
+
center_elem: str, cap_elem: str) -> None:
|
|
154
|
+
"""Place a *cap_elem* atom along each unit direction in *dirs*.
|
|
155
|
+
|
|
156
|
+
Every atom placed here is editor-created, so it joins ``mol.new_atoms``,
|
|
157
|
+
and its bond to *center_idx* is deliberate, so it is recorded as a
|
|
158
|
+
manual (intended) bond -- this single spot covers the caps for
|
|
159
|
+
:func:`birth_molecule`, :func:`replace_atom`'s valence fill, and
|
|
160
|
+
:func:`_promote_hydrogen`'s freed valences all at once.
|
|
161
|
+
"""
|
|
162
|
+
r = _bond_length(center_elem, cap_elem)
|
|
163
|
+
for d in dirs:
|
|
164
|
+
p = center + np.asarray(d, float) * r
|
|
165
|
+
idx = mol.add_atom(cap_elem, p[0], p[1], p[2])
|
|
166
|
+
mol.new_atoms.add(idx)
|
|
167
|
+
_record_intended_bond(mol, center_idx, idx)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Sample density for the fallback direction search: fine enough that the
|
|
171
|
+
# worst-case angular error (~7 deg) is invisible at bond scale, still cheap.
|
|
172
|
+
_FILL_SPHERE_SAMPLES = 256
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _fibonacci_sphere(n: int) -> np.ndarray:
|
|
176
|
+
"""*n* roughly-evenly distributed unit vectors."""
|
|
177
|
+
i = np.arange(n) + 0.5
|
|
178
|
+
phi = np.arccos(1.0 - 2.0 * i / n)
|
|
179
|
+
theta = np.pi * (1.0 + 5.0 ** 0.5) * i
|
|
180
|
+
return np.stack([np.sin(phi) * np.cos(theta),
|
|
181
|
+
np.sin(phi) * np.sin(theta),
|
|
182
|
+
np.cos(phi)], axis=1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _fill_directions(existing, n: int) -> np.ndarray:
|
|
186
|
+
"""Pick *n* unit directions, one at a time, each far from all others.
|
|
187
|
+
|
|
188
|
+
``templates.free_direction`` handles the generic VSEPR-ish case, but it
|
|
189
|
+
degenerates on symmetric arrangements (it can return a direction that is
|
|
190
|
+
already occupied). When its answer lies within 60 degrees of an occupied
|
|
191
|
+
direction we instead take the sphere point maximizing the minimum angle
|
|
192
|
+
to everything placed so far.
|
|
193
|
+
"""
|
|
194
|
+
dirs = [templates._normalize(d) for d in existing]
|
|
195
|
+
out = []
|
|
196
|
+
for _ in range(n):
|
|
197
|
+
d = templates.free_direction(dirs)
|
|
198
|
+
if dirs and max(float(np.dot(d, e)) for e in dirs) > 0.5: # cos 60 deg
|
|
199
|
+
pts = _fibonacci_sphere(_FILL_SPHERE_SAMPLES)
|
|
200
|
+
worst = (pts @ np.array(dirs).T).max(axis=1) # closest occupied, per point
|
|
201
|
+
d = pts[int(np.argmin(worst))]
|
|
202
|
+
out.append(d)
|
|
203
|
+
dirs.append(d)
|
|
204
|
+
return np.array(out)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def replace_atom(mol: Molecule, idx: int, element: str = "C",
|
|
208
|
+
template: Optional[AtomTemplate] = None) -> int:
|
|
209
|
+
"""Replace atom *idx* with *element*, keeping it anchored in place.
|
|
210
|
+
|
|
211
|
+
The atom is relabeled where it stands. Terminal hydrogen neighbors are
|
|
212
|
+
snapped to the new element-H bond length along their existing directions;
|
|
213
|
+
heavy neighbors never move. If coordination is below the template's
|
|
214
|
+
valence, hydrogens are added on free sites until it is met; excess
|
|
215
|
+
coordination is left alone (hypervalency is the user's business).
|
|
216
|
+
|
|
217
|
+
Returns *idx*.
|
|
218
|
+
"""
|
|
219
|
+
element = elements.normalize_symbol(element)
|
|
220
|
+
tmpl = template or templates.default_template(element)
|
|
221
|
+
pos = mol.positions[idx].copy()
|
|
222
|
+
neigh = _neighbors(mol, idx)
|
|
223
|
+
mol.symbols[idx] = element
|
|
224
|
+
for j in neigh:
|
|
225
|
+
if mol.symbols[j] == "H" and _neighbors(mol, j) == [idx]:
|
|
226
|
+
d = templates._normalize(mol.positions[j] - pos)
|
|
227
|
+
mol.positions[j] = pos + d * _bond_length(element, "H")
|
|
228
|
+
n_add = tmpl.valence - len(neigh)
|
|
229
|
+
if n_add > 0:
|
|
230
|
+
if not neigh: # lone atom: full template, like birth_molecule
|
|
231
|
+
_cap(mol, idx, pos, tmpl.free_directions(), element, tmpl.cap)
|
|
232
|
+
else:
|
|
233
|
+
existing = [templates._normalize(mol.positions[j] - pos) for j in neigh]
|
|
234
|
+
_cap(mol, idx, pos, _fill_directions(existing, n_add), element, tmpl.cap)
|
|
235
|
+
_reperceive(mol)
|
|
236
|
+
return idx
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def delete_atom(mol: Molecule, idx: int) -> None:
|
|
240
|
+
"""Remove atom *idx* (and its own terminal hydrogens) from the molecule.
|
|
241
|
+
|
|
242
|
+
Terminal-H neighbors of *idx* -- hydrogens bonded to nothing but *idx* --
|
|
243
|
+
are swept away with it. That one rule covers both gestures: deleting a
|
|
244
|
+
heavy atom takes its whole hydrogen shell (methane's carbon removes the
|
|
245
|
+
CH4); deleting a hydrogen has no hydrogens of its own, so only it goes.
|
|
246
|
+
|
|
247
|
+
Bonds are re-perceived rather than reindexed, and no auto-capping is done:
|
|
248
|
+
heavy neighbors that lose a bond are left dangling and under-coordinated
|
|
249
|
+
(deletion is a plain erase, not a substitution like :func:`replace_atom`).
|
|
250
|
+
"""
|
|
251
|
+
victims = {idx}
|
|
252
|
+
for j in _neighbors(mol, idx):
|
|
253
|
+
if mol.symbols[j] == "H" and _neighbors(mol, j) == [idx]:
|
|
254
|
+
victims.add(j)
|
|
255
|
+
# Surviving neighbors of a deleted atom just lost a bond -- their local
|
|
256
|
+
# geometry may no longer be ideal (e.g. a tetrahedral center left with 3
|
|
257
|
+
# neighbors still at 109.47 deg instead of a relaxed trigonal 120 deg),
|
|
258
|
+
# so they need the same cleanup attention as a freshly created atom.
|
|
259
|
+
affected = {j for v in victims for j in _neighbors(mol, v) if j not in victims}
|
|
260
|
+
# Marking a survivor as needing cleanup makes its bonds eligible for
|
|
261
|
+
# clash scrutiny (see cleanup_targets) -- but its OTHER bonds predate
|
|
262
|
+
# this deletion and are untouched by it, so they can never be a
|
|
263
|
+
# proximity accident. Promote them to manual now, before that
|
|
264
|
+
# eligibility exists, or a later cleanup could mistake perfectly good
|
|
265
|
+
# connectivity for a false bond and push it past the perception cutoff.
|
|
266
|
+
for a in affected:
|
|
267
|
+
for b in _neighbors(mol, a):
|
|
268
|
+
if b not in victims:
|
|
269
|
+
_record_intended_bond(mol, a, b)
|
|
270
|
+
keep = [i for i in range(mol.n_atoms) if i not in victims]
|
|
271
|
+
# Remap manual bonds across the row removal *before* rebuilding symbols/
|
|
272
|
+
# positions below: drop any pair touching a deleted atom, and shift the
|
|
273
|
+
# survivors' indices old -> new via the same mapping `keep` implies.
|
|
274
|
+
# `keep` is ascending, so remapping never reorders a surviving pair.
|
|
275
|
+
remap = {old: new for new, old in enumerate(keep)}
|
|
276
|
+
mol.manual_bonds = [
|
|
277
|
+
(remap[i], remap[j], order)
|
|
278
|
+
for i, j, order in mol.manual_bonds
|
|
279
|
+
if i not in victims and j not in victims
|
|
280
|
+
]
|
|
281
|
+
mol.new_atoms = {remap[i] for i in mol.new_atoms if i not in victims} \
|
|
282
|
+
| {remap[j] for j in affected}
|
|
283
|
+
mol.symbols = [mol.symbols[i] for i in keep]
|
|
284
|
+
# fancy-index the surviving rows (a copy); empty keep -> a clean (0, 3) array
|
|
285
|
+
mol.positions = mol.positions[keep]
|
|
286
|
+
_reperceive(mol)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def birth_molecule(mol: Molecule, position, element: str = "C",
|
|
290
|
+
template: Optional[AtomTemplate] = None) -> int:
|
|
291
|
+
"""Create a fresh, fully-capped atom at *position* (e.g. methane).
|
|
292
|
+
|
|
293
|
+
Returns the index of the central atom. The central atom gets one cap in
|
|
294
|
+
every template direction, so carbon becomes CH4, oxygen becomes water, etc.
|
|
295
|
+
"""
|
|
296
|
+
tmpl = template or templates.default_template(element)
|
|
297
|
+
p = np.asarray(position, float)
|
|
298
|
+
center = mol.add_atom(element, p[0], p[1], p[2])
|
|
299
|
+
mol.new_atoms.add(center)
|
|
300
|
+
_cap(mol, center, p, tmpl.free_directions(), element, tmpl.cap)
|
|
301
|
+
_reperceive(mol)
|
|
302
|
+
return center
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _promote_hydrogen(mol: Molecule, h_idx: int, element: str,
|
|
306
|
+
template: AtomTemplate) -> int:
|
|
307
|
+
"""Turn a hydrogen into *element*, repositioned and capped.
|
|
308
|
+
|
|
309
|
+
The H is bonded to some parent P. We move it out to the correct
|
|
310
|
+
``element``-P bond length along the same P->H direction, relabel it, and cap
|
|
311
|
+
its freed valences. With no parent (a lone H) it simply becomes the center
|
|
312
|
+
of a fresh capped atom in place.
|
|
313
|
+
"""
|
|
314
|
+
neigh = _neighbors(mol, h_idx)
|
|
315
|
+
if not neigh:
|
|
316
|
+
# lone hydrogen: reuse it as the center of a free molecule
|
|
317
|
+
mol.symbols[h_idx] = elements.normalize_symbol(element)
|
|
318
|
+
mol.new_atoms.add(h_idx) # moved and changed element -> new segment
|
|
319
|
+
_cap(mol, h_idx, mol.positions[h_idx].copy(), template.free_directions(),
|
|
320
|
+
element, template.cap)
|
|
321
|
+
_reperceive(mol)
|
|
322
|
+
return h_idx
|
|
323
|
+
parent = neigh[0]
|
|
324
|
+
parent_pos = mol.positions[parent].copy()
|
|
325
|
+
parent_elem = mol.symbols[parent]
|
|
326
|
+
d = templates._normalize(mol.positions[h_idx] - parent_pos) # parent -> H
|
|
327
|
+
center = parent_pos + d * _bond_length(parent_elem, element)
|
|
328
|
+
mol.symbols[h_idx] = elements.normalize_symbol(element)
|
|
329
|
+
mol.positions[h_idx] = center
|
|
330
|
+
mol.new_atoms.add(h_idx) # moved and changed element -> new segment
|
|
331
|
+
_record_intended_bond(mol, parent, h_idx) # the bond being grown across
|
|
332
|
+
opens = template.open_directions(-d)
|
|
333
|
+
_cap(mol, h_idx, center, opens, element, template.cap)
|
|
334
|
+
_reperceive(mol)
|
|
335
|
+
return h_idx
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def grow_at_atom(mol: Molecule, idx: int, element: str = "C",
|
|
339
|
+
template: Optional[AtomTemplate] = None) -> int:
|
|
340
|
+
"""Edit the structure at atom *idx* with the selected *element*.
|
|
341
|
+
|
|
342
|
+
* A hydrogen is *promoted*: it becomes the new element (moved to the right
|
|
343
|
+
bond length) and its freed valences are capped -- "click an H, it turns
|
|
344
|
+
into a carbon with three new hydrogens".
|
|
345
|
+
* Any heavier atom is *replaced* in place -- see :func:`replace_atom`.
|
|
346
|
+
|
|
347
|
+
Returns the index of the resulting central atom.
|
|
348
|
+
"""
|
|
349
|
+
tmpl = template or templates.default_template(element)
|
|
350
|
+
if mol.symbols[idx] == "H":
|
|
351
|
+
return _promote_hydrogen(mol, idx, element, tmpl)
|
|
352
|
+
return replace_atom(mol, idx, element, tmpl)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Perception's own distance tolerance (see bonds.perceive_bonds) -- a manual
|
|
356
|
+
# bond only counts as "stretched" once it exceeds even that generous margin.
|
|
357
|
+
_PERCEPTION_TOLERANCE = 0.45
|
|
358
|
+
# How far a clash pair is pushed past the perception cutoff during cleanup,
|
|
359
|
+
# so the false bond is unambiguously gone (not just barely) on re-perception.
|
|
360
|
+
_CLASH_CLEARANCE = 0.2
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def cleanup_targets(mol: Molecule) -> Tuple[List[Tuple[int, int]], List[Tuple[int, int]]]:
|
|
364
|
+
"""(clash_pairs, stretched_manual) that pressing 'c' would fix.
|
|
365
|
+
|
|
366
|
+
A pure, cheap function of current state -- no event bookkeeping -- so
|
|
367
|
+
callers (e.g. the status-bar hint) can call it every render and always
|
|
368
|
+
get an honest answer.
|
|
369
|
+
|
|
370
|
+
* clash pair: a perceived bond with at least one endpoint in
|
|
371
|
+
``mol.new_atoms`` whose pair is NOT in ``mol.manual_bonds``. Every
|
|
372
|
+
bond the editor deliberately creates is recorded as a manual bond
|
|
373
|
+
(see :func:`_record_intended_bond`), so intent is recorded rather
|
|
374
|
+
than inferred: any unrecorded new-atom bond is a proximity accident,
|
|
375
|
+
even one at exactly the ideal length. Old-old bonds are never
|
|
376
|
+
flagged -- loaded geometry is not ours to judge.
|
|
377
|
+
* stretched manual bond: a manual bond longer than
|
|
378
|
+
``ideal + _PERCEPTION_TOLERANCE`` -- i.e. one that only exists because
|
|
379
|
+
it is manual, not because the atoms are actually close.
|
|
380
|
+
"""
|
|
381
|
+
clash_pairs: List[Tuple[int, int]] = []
|
|
382
|
+
for i, j, _order in mol.bonds:
|
|
383
|
+
if i not in mol.new_atoms and j not in mol.new_atoms:
|
|
384
|
+
continue
|
|
385
|
+
if any(mi == i and mj == j for mi, mj, _mo in mol.manual_bonds):
|
|
386
|
+
continue
|
|
387
|
+
clash_pairs.append((i, j))
|
|
388
|
+
|
|
389
|
+
stretched: List[Tuple[int, int]] = []
|
|
390
|
+
for i, j, _order in mol.manual_bonds:
|
|
391
|
+
ideal = _bond_length(mol.symbols[i], mol.symbols[j])
|
|
392
|
+
length = float(np.linalg.norm(mol.positions[i] - mol.positions[j]))
|
|
393
|
+
if length > ideal + _PERCEPTION_TOLERANCE:
|
|
394
|
+
stretched.append((i, j))
|
|
395
|
+
|
|
396
|
+
return clash_pairs, stretched
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# Angle (1-3 / Urey-Bradley) springs act at half the bond springs' stiffness:
|
|
400
|
+
# they shape, the bond springs place -- and halving keeps the iteration stable
|
|
401
|
+
# for 4-coordinate centers, which gain several extra springs per atom.
|
|
402
|
+
_ANGLE_STIFFNESS = 0.5
|
|
403
|
+
# The unregistered-coordination repulsion fallback (theta=180) targets a
|
|
404
|
+
# configuration no arrangement of >2 points can actually reach -- every pair
|
|
405
|
+
# simultaneously antipodal is geometrically impossible past 2 neighbors -- so
|
|
406
|
+
# it never settles to zero force. Kept soft and repulsive-only (see
|
|
407
|
+
# cleanup_advance) so it stops pulling once neighbors are reasonably spread,
|
|
408
|
+
# instead of perpetually over-stretching the real bonds to those neighbors.
|
|
409
|
+
_REPULSION_STIFFNESS = 0.08
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _build_springs(mol: Molecule, clash_pairs, stretched) -> List[Tuple[int, int, float, float, bool]]:
|
|
413
|
+
"""The fixed spring set for one cleanup run: (i, j, target, stiffness, repulsive_only).
|
|
414
|
+
|
|
415
|
+
Bond springs (stiffness 1.0, not repulsive-only), one per currently
|
|
416
|
+
bonded pair:
|
|
417
|
+
|
|
418
|
+
* clash pairs are pushed to just past the perception cutoff, so the
|
|
419
|
+
false bond they created disappears on re-perception;
|
|
420
|
+
* stretched manual bonds are pulled to the covalent-sum ideal, so they
|
|
421
|
+
read as a real bond;
|
|
422
|
+
* every other current bond holds its *current* length -- not the
|
|
423
|
+
covalent ideal, which would distort a loaded real-world structure --
|
|
424
|
+
simply keeping the rest of the molecule from falling apart while the
|
|
425
|
+
two groups above move.
|
|
426
|
+
|
|
427
|
+
Angle springs: classic 1-3 (Urey-Bradley) springs around each *center*
|
|
428
|
+
in ``mol.new_atoms`` plus the endpoints of stretched manual bonds, so
|
|
429
|
+
bond-length springs cannot collapse the local geometry. The center's
|
|
430
|
+
template (``TEMPLATES[(symbol, n_neighbors)]``) gives the ideal angle
|
|
431
|
+
theta, at :data:`_ANGLE_STIFFNESS`; a coordination with no registered
|
|
432
|
+
template falls back to theta=180 (repulsion) at the much gentler
|
|
433
|
+
:data:`_REPULSION_STIFFNESS`, and *repulsive-only* -- it never pulls a
|
|
434
|
+
pair back together once they're already past the (unreachable) target,
|
|
435
|
+
only pushes apart when closer. Each pair of the center's bonded
|
|
436
|
+
neighbors gets a spring at the law-of-cosines distance for theta over
|
|
437
|
+
the two bond springs' targets.
|
|
438
|
+
Old, uninvolved centers get none -- loaded geometry is still not ours
|
|
439
|
+
to judge. A neighbor pair that already carries a bond spring keeps it:
|
|
440
|
+
a clash spring's target is raised to the angle target when the angle
|
|
441
|
+
target is longer (one spring per pair, and the geometry still clears
|
|
442
|
+
the perception cutoff); a real bond between the two neighbors (a ring)
|
|
443
|
+
is never fought.
|
|
444
|
+
"""
|
|
445
|
+
clash_set = set(clash_pairs)
|
|
446
|
+
targets = {} # (i, j) -> bond-spring target, i < j
|
|
447
|
+
for i, j in clash_pairs:
|
|
448
|
+
ideal = _bond_length(mol.symbols[i], mol.symbols[j])
|
|
449
|
+
targets[(i, j)] = ideal + _PERCEPTION_TOLERANCE + _CLASH_CLEARANCE
|
|
450
|
+
for i, j in stretched:
|
|
451
|
+
targets[(i, j)] = _bond_length(mol.symbols[i], mol.symbols[j])
|
|
452
|
+
for i, j, _order in mol.bonds:
|
|
453
|
+
if (i, j) not in targets:
|
|
454
|
+
targets[(i, j)] = float(np.linalg.norm(mol.positions[i] - mol.positions[j]))
|
|
455
|
+
|
|
456
|
+
neighbors = {} # adjacency over the current bond list
|
|
457
|
+
for i, j, _order in mol.bonds:
|
|
458
|
+
neighbors.setdefault(i, []).append(j)
|
|
459
|
+
neighbors.setdefault(j, []).append(i)
|
|
460
|
+
|
|
461
|
+
centers = set(mol.new_atoms)
|
|
462
|
+
for i, j in stretched:
|
|
463
|
+
centers.add(i)
|
|
464
|
+
centers.add(j)
|
|
465
|
+
|
|
466
|
+
angle_targets = {} # (a, b) -> (1-3 target, repulsive_only, stiffness)
|
|
467
|
+
for k in sorted(centers):
|
|
468
|
+
neigh = neighbors.get(k, [])
|
|
469
|
+
if len(neigh) < 2:
|
|
470
|
+
continue
|
|
471
|
+
tmpl = templates.TEMPLATES.get((mol.symbols[k], len(neigh)))
|
|
472
|
+
repulsive_only = tmpl is None
|
|
473
|
+
if repulsive_only:
|
|
474
|
+
# Unregistered coordination (e.g. a 5-coordinate carbon from
|
|
475
|
+
# linking two methanes): fall back to pure repulsion. theta=180
|
|
476
|
+
# makes each pair's law-of-cosines target the antipodal chord
|
|
477
|
+
# La+Lb -- unreachable for >2 neighbors, so this floor is kept
|
|
478
|
+
# repulsive-only and gentle (see cleanup_advance / the module
|
|
479
|
+
# constant above).
|
|
480
|
+
cos_t = -1.0
|
|
481
|
+
stiff = _REPULSION_STIFFNESS
|
|
482
|
+
else:
|
|
483
|
+
cos_t = float(np.dot(tmpl.directions[0], tmpl.directions[1]))
|
|
484
|
+
stiff = _ANGLE_STIFFNESS
|
|
485
|
+
neigh = sorted(neigh)
|
|
486
|
+
for x in range(len(neigh)):
|
|
487
|
+
for y in range(x + 1, len(neigh)):
|
|
488
|
+
a, b = neigh[x], neigh[y]
|
|
489
|
+
la = targets[(k, a) if k < a else (a, k)]
|
|
490
|
+
lb = targets[(k, b) if k < b else (b, k)]
|
|
491
|
+
t = float(np.sqrt(la * la + lb * lb - 2.0 * la * lb * cos_t))
|
|
492
|
+
if (a, b) in clash_set:
|
|
493
|
+
# the false bond joins two intended neighbors: let the
|
|
494
|
+
# geometry target win (it clears the cutoff by more)
|
|
495
|
+
targets[(a, b)] = max(targets[(a, b)], t)
|
|
496
|
+
elif (a, b) in targets:
|
|
497
|
+
continue # a real neighbor-neighbor bond (a ring)
|
|
498
|
+
elif (a, b) not in angle_targets:
|
|
499
|
+
angle_targets[(a, b)] = (t, repulsive_only, stiff)
|
|
500
|
+
|
|
501
|
+
springs = [(i, j, t, 1.0, False) for (i, j), t in targets.items()]
|
|
502
|
+
springs += [(a, b, t, stiff, rep) for (a, b), (t, rep, stiff) in angle_targets.items()]
|
|
503
|
+
return springs
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@dataclass
|
|
507
|
+
class RelaxState:
|
|
508
|
+
"""An in-flight cleanup relaxation, fixed at :func:`cleanup_prepare` time.
|
|
509
|
+
|
|
510
|
+
``springs`` is the (i, j, target, stiffness, repulsive_only) list from
|
|
511
|
+
:func:`_build_springs`; ``weights`` is the per-atom mobility vector (1.0
|
|
512
|
+
for new atoms, 0.15 otherwise); ``final_bonds`` is the connectivity the
|
|
513
|
+
molecule will have when the relaxation finishes -- the press-time bond
|
|
514
|
+
list minus the clash pairs, frozen here so :func:`cleanup_finish` can
|
|
515
|
+
assign it verbatim. All three stay constant across
|
|
516
|
+
:func:`cleanup_advance` calls -- nothing is re-derived from the moving
|
|
517
|
+
geometry.
|
|
518
|
+
"""
|
|
519
|
+
springs: List[Tuple[int, int, float, float, bool]]
|
|
520
|
+
weights: np.ndarray
|
|
521
|
+
final_bonds: List[Tuple[int, int, int]]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# A spring is considered "already at target" below this displacement -- well
|
|
525
|
+
# under chemical significance, just enough to absorb floating-point noise.
|
|
526
|
+
_PREPARE_EPS = 1e-3
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def cleanup_prepare(mol: Molecule) -> Optional[RelaxState]:
|
|
530
|
+
"""Set up a cleanup relaxation, or None when there is nothing to fix.
|
|
531
|
+
|
|
532
|
+
Builds the full spring set (see :func:`_build_springs`) and freezes it,
|
|
533
|
+
with per-atom weights, into a :class:`RelaxState` for
|
|
534
|
+
:func:`cleanup_advance` to step through. Atoms in ``mol.new_atoms`` get
|
|
535
|
+
weight 1.0 and the rest 0.15 (~7x less): the new/edited segment does
|
|
536
|
+
almost all the moving, but old atoms are not frozen solid (a fully
|
|
537
|
+
pinned clash could be unresolvable).
|
|
538
|
+
|
|
539
|
+
Available any time there is a real spring to relax -- not just the
|
|
540
|
+
urgent clash/stretched-manual-bond cases :func:`cleanup_targets` flags
|
|
541
|
+
for the status-bar hint. A spring on an already-ideal bond or angle has
|
|
542
|
+
its target equal to the current geometry by construction, so it never
|
|
543
|
+
trips this check; one only fires when a bond is a clash/stretched, or
|
|
544
|
+
when a center in ``mol.new_atoms`` (which :func:`delete_atom` also
|
|
545
|
+
populates with a deletion's surviving neighbors) sits away from its
|
|
546
|
+
ideal angle -- e.g. three leftover substituents still at a tetrahedral
|
|
547
|
+
spacing after their fourth was deleted.
|
|
548
|
+
"""
|
|
549
|
+
clash_pairs, stretched = cleanup_targets(mol)
|
|
550
|
+
springs = _build_springs(mol, clash_pairs, stretched)
|
|
551
|
+
pos = mol.positions
|
|
552
|
+
|
|
553
|
+
def _spring_deviates(i, j, target, rep):
|
|
554
|
+
length = float(np.linalg.norm(pos[j] - pos[i]))
|
|
555
|
+
if rep and length >= target: # repulsive-only: already spread enough
|
|
556
|
+
return False
|
|
557
|
+
return abs(length - target) > _PREPARE_EPS
|
|
558
|
+
|
|
559
|
+
if not any(_spring_deviates(i, j, t, rep) for i, j, t, _stiff, rep in springs):
|
|
560
|
+
return None
|
|
561
|
+
weights = np.array([1.0 if k in mol.new_atoms else 0.15 for k in range(mol.n_atoms)])
|
|
562
|
+
# Connectivity is FROZEN input to a cleanup: the implicit distance bonds
|
|
563
|
+
# of old atoms plus the explicit manual bonds, as they stand right now.
|
|
564
|
+
# The only change cleanup may make to it is removing the clash pairs --
|
|
565
|
+
# cleanup_finish assigns this list verbatim rather than re-perceiving,
|
|
566
|
+
# so relaxation motion can neither mint a bond (two hydrogens drifting
|
|
567
|
+
# within range) nor drop one (a bond stretched past the cutoff).
|
|
568
|
+
clash_set = set(clash_pairs)
|
|
569
|
+
final_bonds = [(i, j, o) for i, j, o in mol.bonds if (i, j) not in clash_set]
|
|
570
|
+
return RelaxState(springs=springs, weights=weights, final_bonds=final_bonds)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def cleanup_advance(mol: Molecule, state: RelaxState,
|
|
574
|
+
iterations: int = 4, step: float = 0.15) -> float:
|
|
575
|
+
"""Run a few spring iterations in place; return the max atom displacement.
|
|
576
|
+
|
|
577
|
+
Each iteration accumulates the axial force ``stiffness * (L - target) *
|
|
578
|
+
unit(i->j)`` per spring (attractive when too long, repulsive when too
|
|
579
|
+
short; pairs with ``L < 1e-6`` are skipped) and moves every atom by
|
|
580
|
+
``step * weight * force``. A repulsive-only spring (the unregistered-
|
|
581
|
+
coordination fallback -- see :func:`_build_springs`) contributes nothing
|
|
582
|
+
once ``L >= target``: its target is a geometrically unreachable ideal
|
|
583
|
+
for >2 neighbors, so without this it would pull forever and
|
|
584
|
+
over-stretch the real bonds to those neighbors as a side effect. The
|
|
585
|
+
return value -- the largest single-atom displacement over the whole
|
|
586
|
+
call -- is the caller's convergence signal. Bonds are deliberately NOT
|
|
587
|
+
re-perceived here: mid-animation the clash bond visibly stretches as
|
|
588
|
+
the fragments separate, and pops off in :func:`cleanup_finish`.
|
|
589
|
+
"""
|
|
590
|
+
pos = mol.positions
|
|
591
|
+
start = pos.copy()
|
|
592
|
+
for _ in range(iterations):
|
|
593
|
+
forces = np.zeros_like(pos)
|
|
594
|
+
for i, j, target, stiff, rep in state.springs:
|
|
595
|
+
delta = pos[j] - pos[i]
|
|
596
|
+
length = float(np.linalg.norm(delta))
|
|
597
|
+
if length < 1e-6: # perception already rejects sub-0.4A contacts
|
|
598
|
+
continue
|
|
599
|
+
deviation = length - target
|
|
600
|
+
if rep and deviation >= 0:
|
|
601
|
+
continue
|
|
602
|
+
f = (stiff * deviation) * (delta / length)
|
|
603
|
+
forces[i] += f
|
|
604
|
+
forces[j] -= f
|
|
605
|
+
pos = pos + step * state.weights[:, None] * forces
|
|
606
|
+
mol.positions = pos
|
|
607
|
+
if mol.n_atoms == 0:
|
|
608
|
+
return 0.0
|
|
609
|
+
return float(np.linalg.norm(pos - start, axis=1).max())
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def cleanup_finish(mol: Molecule, state: RelaxState) -> None:
|
|
613
|
+
"""Accept a relaxed geometry: assign the frozen connectivity, clear ``new_atoms``.
|
|
614
|
+
|
|
615
|
+
Connectivity was decided when the cleanup was prepared (press-time bonds
|
|
616
|
+
minus the clash pairs -- see :class:`RelaxState`); it is assigned here
|
|
617
|
+
VERBATIM, never re-perceived. Where the atoms ended up cannot change what
|
|
618
|
+
is bonded: two hydrogens the relaxation happened to push within range do
|
|
619
|
+
not gain a bond, a bond it stretched past the cutoff does not lose one,
|
|
620
|
+
and the clash bonds are gone even if their pair didn't quite clear the
|
|
621
|
+
threshold in the frame budget. Clearing ``new_atoms`` means repeated 'c'
|
|
622
|
+
presses do not keep kneading the molecule (and the hint goes away).
|
|
623
|
+
"""
|
|
624
|
+
mol.bonds = list(state.final_bonds)
|
|
625
|
+
mol.new_atoms.clear()
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def cleanup(mol: Molecule, iterations: int = 100, step: float = 0.2) -> bool:
|
|
629
|
+
"""Relax steric clashes and stretched manual bonds ('c'), in one shot.
|
|
630
|
+
|
|
631
|
+
The convenience composition of :func:`cleanup_prepare` ->
|
|
632
|
+
:func:`cleanup_advance` -> :func:`cleanup_finish` -- a deliberately
|
|
633
|
+
simple fixed-target spring relaxation (see :func:`_build_springs`), not
|
|
634
|
+
a force field. Interactive hosts that want the relaxation animated call
|
|
635
|
+
the three stages themselves, a few iterations per frame.
|
|
636
|
+
|
|
637
|
+
Returns False (no change) when there is nothing to fix; otherwise
|
|
638
|
+
relaxes, assigns the frozen press-time connectivity (minus clash pairs),
|
|
639
|
+
"accepts" the result by clearing ``mol.new_atoms``, and returns True.
|
|
640
|
+
"""
|
|
641
|
+
state = cleanup_prepare(mol)
|
|
642
|
+
if state is None:
|
|
643
|
+
return False
|
|
644
|
+
cleanup_advance(mol, state, iterations=iterations, step=step)
|
|
645
|
+
cleanup_finish(mol, state)
|
|
646
|
+
return True
|