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/scene.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""A Scene binds a molecule, a camera, a style and a renderer together.
|
|
2
|
+
|
|
3
|
+
It is the main object embedding apps interact with: set the pixel size, call
|
|
4
|
+
:meth:`render` to get an RGB numpy array, or :meth:`to_kitty` for protocol
|
|
5
|
+
bytes ready to write to a terminal.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .camera import Camera
|
|
14
|
+
from .molecule import Molecule
|
|
15
|
+
from .render import Renderer, Style
|
|
16
|
+
from . import kitty
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _block_sums(img: np.ndarray, factor: int) -> np.ndarray:
|
|
20
|
+
"""Per-block uint16 channel sums over factor x factor tiles.
|
|
21
|
+
|
|
22
|
+
Strided-slice accumulation instead of ``reshape(...).mean(axis=(1, 3))``:
|
|
23
|
+
the reshape-mean runs in float64 over two non-contiguous axes and was
|
|
24
|
+
the single most expensive step of a supersampled frame (~170 ms at
|
|
25
|
+
3200x2000); f*f strided adds in uint16 do the same reduction ~6x faster.
|
|
26
|
+
uint16 is safe up to factor 16 (16*16*255 < 65536).
|
|
27
|
+
"""
|
|
28
|
+
h, w = img.shape[:2]
|
|
29
|
+
ch = img.shape[2]
|
|
30
|
+
h2, w2 = h // factor, w // factor
|
|
31
|
+
img = img[: h2 * factor, : w2 * factor]
|
|
32
|
+
acc = np.zeros((h2, w2, ch), np.uint16)
|
|
33
|
+
for dy in range(factor):
|
|
34
|
+
for dx in range(factor):
|
|
35
|
+
acc += img[dy::factor, dx::factor]
|
|
36
|
+
return acc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _downsample(img: np.ndarray, factor: int) -> np.ndarray:
|
|
40
|
+
if factor <= 1:
|
|
41
|
+
return img
|
|
42
|
+
ch = img.shape[2]
|
|
43
|
+
sums = _block_sums(img, factor)
|
|
44
|
+
if ch == 4:
|
|
45
|
+
# premultiplied-alpha average so anti-aliased edges don't fringe toward
|
|
46
|
+
# black over a transparent background. RGB is already premultiplied
|
|
47
|
+
# (undrawn pixels are 0,0,0,0), so straight = sum(rgb)/coverage.
|
|
48
|
+
rgb_sum = sums[..., :3].astype(np.float32)
|
|
49
|
+
a_sum = sums[..., 3].astype(np.float32)
|
|
50
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
51
|
+
straight = np.where(a_sum[..., None] > 0, 255.0 * rgb_sum / a_sum[..., None], 0.0)
|
|
52
|
+
alpha = a_sum / (factor * factor)
|
|
53
|
+
out = np.dstack([np.clip(straight, 0, 255), alpha])
|
|
54
|
+
return out.astype(np.uint8)
|
|
55
|
+
# integer floor-average == float mean + truncating uint8 cast, so this is
|
|
56
|
+
# pixel-identical to the old reshape/mean path
|
|
57
|
+
return (sums // (factor * factor)).astype(np.uint8)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Scene:
|
|
61
|
+
def __init__(self, molecule: Molecule, width: int = 640, height: int = 480,
|
|
62
|
+
style: Optional[Style] = None, supersample: int = 1,
|
|
63
|
+
backend: str = "auto"):
|
|
64
|
+
self.molecule = molecule
|
|
65
|
+
self.style = style or Style()
|
|
66
|
+
self.width = width
|
|
67
|
+
self.height = height
|
|
68
|
+
self.supersample = max(1, int(supersample))
|
|
69
|
+
self.render_scale = 1.0 # dynamic-resolution factor, see set_render_scale
|
|
70
|
+
self.camera = Camera(center=molecule.centroid(), extent=molecule.radius_of_gyration_extent())
|
|
71
|
+
self._backend_name, self._renderer = self._make_renderer(
|
|
72
|
+
backend, width * self.supersample, height * self.supersample)
|
|
73
|
+
self.fit()
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _make_renderer(backend: str, w: int, h: int):
|
|
77
|
+
if backend not in ("auto", "cpu", "gl"):
|
|
78
|
+
raise ValueError(f"unknown backend {backend!r} (expected 'auto', 'cpu', or 'gl')")
|
|
79
|
+
if backend == "cpu":
|
|
80
|
+
return "cpu", Renderer(w, h)
|
|
81
|
+
if backend == "gl":
|
|
82
|
+
from .gl_render import GLRenderer # raises if unavailable -- explicit request, no silent fallback
|
|
83
|
+
return "gl", GLRenderer(w, h)
|
|
84
|
+
# "auto": prefer GL, silently fall back to the CPU raycaster if no
|
|
85
|
+
# GL package/context is available (missing moderngl, no driver, no
|
|
86
|
+
# display -- see gl_render.py's docstring).
|
|
87
|
+
try:
|
|
88
|
+
from .gl_render import GLRenderer
|
|
89
|
+
return "gl", GLRenderer(w, h)
|
|
90
|
+
except Exception:
|
|
91
|
+
return "cpu", Renderer(w, h)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def backend(self) -> str:
|
|
95
|
+
"""Which renderer actually got picked: ``"cpu"`` or ``"gl"``."""
|
|
96
|
+
return self._backend_name
|
|
97
|
+
|
|
98
|
+
# -- sizing / framing -------------------------------------------------
|
|
99
|
+
def set_size(self, width: int, height: int, refit: bool = False) -> None:
|
|
100
|
+
"""Resize the viewport. By default this preserves the current
|
|
101
|
+
*apparent framing* -- the fraction of the viewport the molecule fills,
|
|
102
|
+
plus any manual scroll-zoom -- rather than the raw pixels-per-angstrom.
|
|
103
|
+
It does that by rescaling zoom/pan by the min-dimension ratio, exactly
|
|
104
|
+
as :meth:`set_supersample` does for a quality switch.
|
|
105
|
+
|
|
106
|
+
This matters for two reasons. (1) For a fit-derived zoom the rescale
|
|
107
|
+
reproduces *precisely* what a fresh :meth:`fit` would compute for the
|
|
108
|
+
new size (both reduce to ``min(W,H)/2 / (extent*margin)``), so if the
|
|
109
|
+
host learns the real terminal size in two steps -- an early, slightly
|
|
110
|
+
wrong report followed by the settled one -- the second, non-refit
|
|
111
|
+
resize self-heals to the correct framing instead of freezing the
|
|
112
|
+
molecule at the first size's zoom. (2) For a user's manual zoom it
|
|
113
|
+
keeps the molecule the same on-screen fraction across a terminal
|
|
114
|
+
resize, which is the intuitive behaviour.
|
|
115
|
+
|
|
116
|
+
Pass ``refit=True`` to snap to a fresh fit regardless (used the very
|
|
117
|
+
first time a host establishes real geometry from a placeholder size).
|
|
118
|
+
"""
|
|
119
|
+
old_min = max(min(self._renderer.width, self._renderer.height), 1)
|
|
120
|
+
self.width = max(1, int(width))
|
|
121
|
+
self.height = max(1, int(height))
|
|
122
|
+
self._resize_renderer()
|
|
123
|
+
if refit:
|
|
124
|
+
self.fit()
|
|
125
|
+
return
|
|
126
|
+
new_min = max(min(self._renderer.width, self._renderer.height), 1)
|
|
127
|
+
scale = new_min / old_min
|
|
128
|
+
self.camera.zoom *= scale
|
|
129
|
+
self.camera.pan = self.camera.pan * scale
|
|
130
|
+
|
|
131
|
+
def set_supersample(self, factor: int) -> None:
|
|
132
|
+
new_ss = max(1, int(factor))
|
|
133
|
+
if new_ss == self.supersample:
|
|
134
|
+
return
|
|
135
|
+
# Rescale zoom/pan (both in supersampled-buffer pixel units) by the
|
|
136
|
+
# exact buffer-size ratio so the apparent on-screen picture --
|
|
137
|
+
# including any manual scroll-to-zoom -- is preserved across a
|
|
138
|
+
# supersample change. This must NOT go through fit()/Camera.fit():
|
|
139
|
+
# that recomputes zoom purely from the molecule's extent, with no
|
|
140
|
+
# memory of the user's current zoom, so it silently discarded any
|
|
141
|
+
# scroll-zoom every time the interactive quality switch (fast while
|
|
142
|
+
# dragging/scrolling -> crisp ~0.25s after stopping) fired.
|
|
143
|
+
old_min = max(min(self._renderer.width, self._renderer.height), 1)
|
|
144
|
+
self.supersample = new_ss
|
|
145
|
+
self._resize_renderer()
|
|
146
|
+
new_min = max(min(self._renderer.width, self._renderer.height), 1)
|
|
147
|
+
scale = new_min / old_min
|
|
148
|
+
self.camera.zoom *= scale
|
|
149
|
+
self.camera.pan = self.camera.pan * scale
|
|
150
|
+
|
|
151
|
+
def set_render_scale(self, factor: float) -> None:
|
|
152
|
+
"""Render internally at *factor* x the logical resolution (0.1..1).
|
|
153
|
+
|
|
154
|
+
Below 1.0 the scene draws fewer pixels and the terminal (or host)
|
|
155
|
+
stretches the image back up -- slightly soft/grainy, but the frame
|
|
156
|
+
cost drops with the square of the factor. Interactive hosts use this
|
|
157
|
+
as dynamic resolution while the camera is moving, snapping back to
|
|
158
|
+
1.0 (and full supersampling) once idle. Zoom/pan rescale by the exact
|
|
159
|
+
buffer-size ratio, like :meth:`set_supersample`, so the apparent
|
|
160
|
+
framing never changes. The 0.1 hard floor sits below the viewer's own
|
|
161
|
+
operating floor, so it only guards against absurd inputs.
|
|
162
|
+
"""
|
|
163
|
+
f = min(1.0, max(0.1, float(factor)))
|
|
164
|
+
if f == self.render_scale:
|
|
165
|
+
return
|
|
166
|
+
old_min = max(min(self._renderer.width, self._renderer.height), 1)
|
|
167
|
+
self.render_scale = f
|
|
168
|
+
self._resize_renderer()
|
|
169
|
+
new_min = max(min(self._renderer.width, self._renderer.height), 1)
|
|
170
|
+
scale = new_min / old_min
|
|
171
|
+
self.camera.zoom *= scale
|
|
172
|
+
self.camera.pan = self.camera.pan * scale
|
|
173
|
+
|
|
174
|
+
def _resize_renderer(self) -> None:
|
|
175
|
+
self._renderer.resize(
|
|
176
|
+
max(1, int(round(self.width * self.supersample * self.render_scale))),
|
|
177
|
+
max(1, int(round(self.height * self.supersample * self.render_scale))))
|
|
178
|
+
|
|
179
|
+
def fit(self, keep_orientation: bool = False, keep_zoom: bool = False) -> None:
|
|
180
|
+
rot = self.camera.rotation.copy()
|
|
181
|
+
pan = self.camera.pan.copy()
|
|
182
|
+
zoom = self.camera.zoom
|
|
183
|
+
self.camera.center = self.molecule.centroid()
|
|
184
|
+
ext = max(
|
|
185
|
+
self.molecule.radius_of_gyration_extent() + self._max_atom_radius(),
|
|
186
|
+
self.molecule.vector_extent(),
|
|
187
|
+
)
|
|
188
|
+
self.camera.fit(self._renderer.width, self._renderer.height, ext)
|
|
189
|
+
if keep_orientation:
|
|
190
|
+
self.camera.rotation = rot
|
|
191
|
+
self.camera.pan = pan
|
|
192
|
+
if keep_zoom:
|
|
193
|
+
self.camera.zoom = zoom
|
|
194
|
+
|
|
195
|
+
def _max_atom_radius(self) -> float:
|
|
196
|
+
if self.molecule.n_atoms == 0:
|
|
197
|
+
return 0.0
|
|
198
|
+
if self.style.representation == "spacefill":
|
|
199
|
+
return float(self.molecule.vdw_radii().max())
|
|
200
|
+
return float(self.molecule.vdw_radii().max()) * self.style.atom_scale
|
|
201
|
+
|
|
202
|
+
def set_molecule(self, molecule: Molecule) -> None:
|
|
203
|
+
self.molecule = molecule
|
|
204
|
+
self.camera.center = molecule.centroid()
|
|
205
|
+
self.fit()
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def render_size(self):
|
|
209
|
+
"""(width, height) of the internal render buffer, in supersampled px."""
|
|
210
|
+
return self._renderer.width, self._renderer.height
|
|
211
|
+
|
|
212
|
+
# -- rendering --------------------------------------------------------
|
|
213
|
+
def render(self) -> np.ndarray:
|
|
214
|
+
if self._backend_name == "gl":
|
|
215
|
+
from .gl_adapter import molecule_to_gl_inputs
|
|
216
|
+
w, h = self._renderer.width, self._renderer.height
|
|
217
|
+
spheres, cylinders, cones, proj, shading = molecule_to_gl_inputs(
|
|
218
|
+
self.molecule, self.camera, self.style, w, h)
|
|
219
|
+
# the GL renderer supersamples/downsamples on the GPU, so it returns
|
|
220
|
+
# a display-size image directly -- no CPU _downsample pass.
|
|
221
|
+
return self._renderer.render(spheres, cylinders, proj, shading,
|
|
222
|
+
downsample=self.supersample, cones=cones)
|
|
223
|
+
img = self._renderer.render(self.molecule, self.camera, self.style)
|
|
224
|
+
return _downsample(img, self.supersample)
|
|
225
|
+
|
|
226
|
+
def to_kitty(self, *, image_id: Optional[int] = None, cols: Optional[int] = None,
|
|
227
|
+
rows: Optional[int] = None, move_cursor: bool = False) -> bytes:
|
|
228
|
+
img = self.render()
|
|
229
|
+
if image_id is None:
|
|
230
|
+
# per-process id so concurrent Scenes (e.g. two panes of one
|
|
231
|
+
# kitty process) don't clobber each other's image storage.
|
|
232
|
+
image_id = kitty.unique_id_base() + 1
|
|
233
|
+
return kitty.encode_image(img, image_id=image_id, cols=cols, rows=rows,
|
|
234
|
+
move_cursor=move_cursor)
|
|
235
|
+
|
|
236
|
+
def to_png(self, path: str) -> None:
|
|
237
|
+
img = self.render()
|
|
238
|
+
with open(path, "wb") as fh:
|
|
239
|
+
fh.write(kitty.png_bytes(img))
|
vimol/templates.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Geometry templates for interactive molecule building.
|
|
2
|
+
|
|
3
|
+
For each ``(element, valence)`` combination we record the *ideal* local
|
|
4
|
+
arrangement of bonds around a central atom -- tetrahedral for four-coordinate
|
|
5
|
+
carbon, pyramidal for three-coordinate nitrogen, bent for two-coordinate
|
|
6
|
+
oxygen, and so on. Each arrangement is a small set of unit bond directions in a
|
|
7
|
+
canonical local frame.
|
|
8
|
+
|
|
9
|
+
The editor uses these to grow chemically-sensible fragments: place a central
|
|
10
|
+
atom, orient its first direction toward the atom it attaches to, and cap the
|
|
11
|
+
remaining directions with hydrogens.
|
|
12
|
+
|
|
13
|
+
Everything here is pure geometry (unit vectors and rotations); actual atom
|
|
14
|
+
placement, bond lengths and molecule mutation live in :mod:`vimol.editor`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .camera import rotation_from_axis_angle
|
|
23
|
+
from .elements import normalize_symbol as _normalize_symbol
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize(v: np.ndarray) -> np.ndarray:
|
|
27
|
+
v = np.asarray(v, float)
|
|
28
|
+
n = np.linalg.norm(v)
|
|
29
|
+
return v / n if n > 1e-12 else v
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _perpendicular(a: np.ndarray) -> np.ndarray:
|
|
33
|
+
"""Return an arbitrary unit vector perpendicular to *a*."""
|
|
34
|
+
a = _normalize(a)
|
|
35
|
+
# cross with whichever cardinal axis is least aligned with a
|
|
36
|
+
ref = np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
|
37
|
+
return _normalize(np.cross(a, ref))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rotation_between(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""Rotation matrix taking unit vector *a* onto unit vector *b*."""
|
|
42
|
+
a = _normalize(a)
|
|
43
|
+
b = _normalize(b)
|
|
44
|
+
v = np.cross(a, b)
|
|
45
|
+
c = float(np.clip(np.dot(a, b), -1.0, 1.0))
|
|
46
|
+
s = float(np.linalg.norm(v))
|
|
47
|
+
if s < 1e-9: # parallel or antiparallel
|
|
48
|
+
if c > 0:
|
|
49
|
+
return np.eye(3)
|
|
50
|
+
return rotation_from_axis_angle(_perpendicular(a), np.pi)
|
|
51
|
+
return rotation_from_axis_angle(v / s, np.arctan2(s, c))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- canonical direction sets (unit vectors) ------------------------------
|
|
55
|
+
_TETRAHEDRAL = np.array([
|
|
56
|
+
[1.0, 1.0, 1.0],
|
|
57
|
+
[1.0, -1.0, -1.0],
|
|
58
|
+
[-1.0, 1.0, -1.0],
|
|
59
|
+
[-1.0, -1.0, 1.0],
|
|
60
|
+
]) / np.sqrt(3.0)
|
|
61
|
+
|
|
62
|
+
_S3 = np.sqrt(3.0) / 2.0
|
|
63
|
+
_TRIGONAL = np.array([
|
|
64
|
+
[1.0, 0.0, 0.0],
|
|
65
|
+
[-0.5, _S3, 0.0],
|
|
66
|
+
[-0.5, -_S3, 0.0],
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
_LINEAR = np.array([
|
|
70
|
+
[0.0, 0.0, 1.0],
|
|
71
|
+
[0.0, 0.0, -1.0],
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
_TERMINAL = np.array([[0.0, 0.0, 1.0]])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class AtomTemplate:
|
|
79
|
+
"""The ideal local geometry for one ``(element, valence)`` combination.
|
|
80
|
+
|
|
81
|
+
``directions`` is an ``(valence, 3)`` array of unit bond directions in a
|
|
82
|
+
canonical frame. ``directions[0]`` is the *attachment slot* -- the site
|
|
83
|
+
oriented toward the parent atom when the fragment is grown onto an existing
|
|
84
|
+
molecule; ``directions[1:]`` are the remaining open sites that get capped
|
|
85
|
+
(with :attr:`cap`, hydrogen by default). When a fragment is born in free
|
|
86
|
+
space (e.g. a lone methane), every direction is used.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
element: str
|
|
90
|
+
valence: int
|
|
91
|
+
geometry: str
|
|
92
|
+
directions: np.ndarray
|
|
93
|
+
cap: str = "H"
|
|
94
|
+
hybridization: str = "" # "sp3" / "sp2" / "sp"; "" when N/A (e.g. H, halogens)
|
|
95
|
+
|
|
96
|
+
def label(self) -> str:
|
|
97
|
+
"""A short human label, e.g. 'tetrahedral · sp3 · 4 bonds'."""
|
|
98
|
+
bonds = f"{self.valence} bond" + ("s" if self.valence != 1 else "")
|
|
99
|
+
parts = [self.geometry]
|
|
100
|
+
if self.hybridization:
|
|
101
|
+
parts.append(self.hybridization)
|
|
102
|
+
parts.append(bonds)
|
|
103
|
+
return " · ".join(parts)
|
|
104
|
+
|
|
105
|
+
def free_directions(self) -> np.ndarray:
|
|
106
|
+
"""All bond directions in the canonical frame (for a free-standing atom)."""
|
|
107
|
+
return self.directions
|
|
108
|
+
|
|
109
|
+
def open_directions(self, attach_dir: np.ndarray) -> np.ndarray:
|
|
110
|
+
"""Open-site directions when the attachment slot points along *attach_dir*.
|
|
111
|
+
|
|
112
|
+
*attach_dir* is the world-space direction from the new central atom
|
|
113
|
+
toward the atom it bonds to. Returns the ``valence - 1`` remaining bond
|
|
114
|
+
directions, rotated into world space, ready to receive caps.
|
|
115
|
+
"""
|
|
116
|
+
if self.valence <= 1:
|
|
117
|
+
return np.zeros((0, 3))
|
|
118
|
+
R = rotation_between(self.directions[0], _normalize(attach_dir))
|
|
119
|
+
return self.directions[1:] @ R.T
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# -- registry: hybridization/valence options per element ------------------
|
|
123
|
+
# Mirrors the set of atom-type choices a builder like GaussView offers for each
|
|
124
|
+
# element: the standard VSEPR hybridization states (sp3 tetrahedral, sp2
|
|
125
|
+
# trigonal, sp linear, and the lone-pair-reduced pyramidal/bent variants). The
|
|
126
|
+
# order here is the order the geometry picker lists them in (most bonds first).
|
|
127
|
+
TEMPLATES = {
|
|
128
|
+
# Carbon: sp3 / sp2 / sp
|
|
129
|
+
("C", 4): AtomTemplate("C", 4, "tetrahedral", _TETRAHEDRAL, hybridization="sp3"),
|
|
130
|
+
("C", 3): AtomTemplate("C", 3, "trigonal", _TRIGONAL, hybridization="sp2"),
|
|
131
|
+
("C", 2): AtomTemplate("C", 2, "linear", _LINEAR, hybridization="sp"),
|
|
132
|
+
# Nitrogen: ammonium sp3 / amine sp3 / imine sp2 / nitrile sp
|
|
133
|
+
("N", 4): AtomTemplate("N", 4, "tetrahedral", _TETRAHEDRAL, hybridization="sp3"),
|
|
134
|
+
("N", 3): AtomTemplate("N", 3, "pyramidal", _TETRAHEDRAL[:3], hybridization="sp3"),
|
|
135
|
+
("N", 2): AtomTemplate("N", 2, "bent", _TRIGONAL[:2], hybridization="sp2"),
|
|
136
|
+
("N", 1): AtomTemplate("N", 1, "terminal", _TERMINAL, hybridization="sp"),
|
|
137
|
+
# Oxygen: ether/hydroxyl sp3 / carbonyl-like sp2 / terminal
|
|
138
|
+
("O", 2): AtomTemplate("O", 2, "bent", _TETRAHEDRAL[:2], hybridization="sp3"),
|
|
139
|
+
("O", 1): AtomTemplate("O", 1, "terminal", _TERMINAL, hybridization="sp2"),
|
|
140
|
+
# Boron: sp2 / sp3
|
|
141
|
+
("B", 3): AtomTemplate("B", 3, "trigonal", _TRIGONAL, hybridization="sp2"),
|
|
142
|
+
("B", 4): AtomTemplate("B", 4, "tetrahedral", _TETRAHEDRAL, hybridization="sp3"),
|
|
143
|
+
# Heavier main-group with a couple of common valences
|
|
144
|
+
("P", 3): AtomTemplate("P", 3, "pyramidal", _TETRAHEDRAL[:3], hybridization="sp3"),
|
|
145
|
+
("S", 2): AtomTemplate("S", 2, "bent", _TETRAHEDRAL[:2], hybridization="sp3"),
|
|
146
|
+
# Monovalent (halogens, hydrogen): a single terminal bond
|
|
147
|
+
("H", 1): AtomTemplate("H", 1, "terminal", _TERMINAL),
|
|
148
|
+
("F", 1): AtomTemplate("F", 1, "terminal", _TERMINAL),
|
|
149
|
+
("Cl", 1): AtomTemplate("Cl", 1, "terminal", _TERMINAL),
|
|
150
|
+
("Br", 1): AtomTemplate("Br", 1, "terminal", _TERMINAL),
|
|
151
|
+
("I", 1): AtomTemplate("I", 1, "terminal", _TERMINAL),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Typical valence used to pick a default template when none is specified.
|
|
155
|
+
_DEFAULT_VALENCE = {"C": 4, "N": 3, "O": 2, "B": 3, "P": 3, "S": 2,
|
|
156
|
+
"H": 1, "F": 1, "Cl": 1, "Br": 1, "I": 1}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def default_template(element: str = "C") -> AtomTemplate:
|
|
160
|
+
"""The default building template for *element* (its most common valence).
|
|
161
|
+
|
|
162
|
+
Falls back to a tetrahedral, carbon-like template for elements without a
|
|
163
|
+
dedicated entry, so an unknown element still grows sensibly.
|
|
164
|
+
"""
|
|
165
|
+
element = _normalize_symbol(element)
|
|
166
|
+
val = _DEFAULT_VALENCE.get(element, 4)
|
|
167
|
+
tmpl = TEMPLATES.get((element, val))
|
|
168
|
+
if tmpl is not None:
|
|
169
|
+
return tmpl
|
|
170
|
+
# synthesize a tetrahedral fallback for the requested element
|
|
171
|
+
return AtomTemplate(element, 4, "tetrahedral", _TETRAHEDRAL, hybridization="sp3")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def options_for(element: str):
|
|
175
|
+
"""The geometry/hybridization templates offered for *element*.
|
|
176
|
+
|
|
177
|
+
Returns every registered template for the element, most bonds first (the
|
|
178
|
+
order the picker lists them). Elements with no dedicated entry fall back to
|
|
179
|
+
a single default template, so the picker always has at least one option.
|
|
180
|
+
"""
|
|
181
|
+
element = _normalize_symbol(element)
|
|
182
|
+
opts = [t for (el, _val), t in TEMPLATES.items() if el == element]
|
|
183
|
+
if not opts:
|
|
184
|
+
return [default_template(element)]
|
|
185
|
+
opts.sort(key=lambda t: -t.valence)
|
|
186
|
+
return opts
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def free_direction(neighbor_dirs) -> np.ndarray:
|
|
190
|
+
"""A sensible open bond direction on an atom that already has neighbors.
|
|
191
|
+
|
|
192
|
+
*neighbor_dirs* is a sequence of unit vectors pointing from the atom toward
|
|
193
|
+
its existing neighbors. The result points away from their average (VSEPR-ish
|
|
194
|
+
"as far from everything else as possible"); with no neighbors it defaults to
|
|
195
|
+
+z, and for a symmetric arrangement (e.g. two opposed bonds) it picks a
|
|
196
|
+
perpendicular direction rather than collapsing to zero.
|
|
197
|
+
"""
|
|
198
|
+
dirs = [np.asarray(d, float) for d in neighbor_dirs]
|
|
199
|
+
if not dirs:
|
|
200
|
+
return np.array([0.0, 0.0, 1.0])
|
|
201
|
+
s = np.sum([_normalize(d) for d in dirs], axis=0)
|
|
202
|
+
n = float(np.linalg.norm(s))
|
|
203
|
+
if n < 1e-6:
|
|
204
|
+
return _perpendicular(dirs[0])
|
|
205
|
+
return -s / n
|