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