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