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/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""vimol — an embeddable terminal molecular viewer using the Kitty graphics protocol.
|
|
2
|
+
|
|
3
|
+
Quick start
|
|
4
|
+
-----------
|
|
5
|
+
import vimol
|
|
6
|
+
|
|
7
|
+
mol = vimol.load("caffeine.pdb") # parse a structure
|
|
8
|
+
scene = vimol.Scene(mol, 640, 480) # bind camera + renderer
|
|
9
|
+
scene.camera.orbit(30, 15) # rotate
|
|
10
|
+
img = scene.render() # (H, W, 3) uint8 numpy array
|
|
11
|
+
|
|
12
|
+
# display in a Kitty-capable terminal:
|
|
13
|
+
import sys, os
|
|
14
|
+
os.write(1, scene.to_kitty())
|
|
15
|
+
|
|
16
|
+
# or run the full interactive viewer:
|
|
17
|
+
vimol.view(mol)
|
|
18
|
+
|
|
19
|
+
Everything here is pure Python + numpy, no GPU or windowing system required.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .molecule import Molecule, VectorField
|
|
24
|
+
from .camera import Camera
|
|
25
|
+
from .render import Renderer, Style
|
|
26
|
+
from .scene import Scene
|
|
27
|
+
from .widget import MoleculeWidget
|
|
28
|
+
from .input import InputDecoder, MouseEvent, KeyEvent, enable_mouse, disable_mouse
|
|
29
|
+
from .parsers import load, load_all, loads, save, SUPPORTED_EXTENSIONS, SAVE_EXTENSIONS
|
|
30
|
+
from .bonds import perceive_bonds, ensure_bonds
|
|
31
|
+
from . import kitty
|
|
32
|
+
from . import elements
|
|
33
|
+
from . import templates
|
|
34
|
+
from . import editor
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Molecule", "VectorField", "Camera", "Renderer", "Style", "Scene", "MoleculeWidget",
|
|
40
|
+
"InputDecoder", "MouseEvent", "KeyEvent", "enable_mouse", "disable_mouse",
|
|
41
|
+
"load", "load_all", "loads", "save", "SUPPORTED_EXTENSIONS", "SAVE_EXTENSIONS",
|
|
42
|
+
"perceive_bonds", "ensure_bonds", "kitty", "elements", "templates", "editor",
|
|
43
|
+
"view", "__version__",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def view(molecule_or_path, **kwargs):
|
|
48
|
+
"""Launch the interactive terminal viewer.
|
|
49
|
+
|
|
50
|
+
Accepts a :class:`Molecule` or a path to a structure file. Extra keyword
|
|
51
|
+
arguments are forwarded to :class:`vimol.viewer.Viewer`.
|
|
52
|
+
"""
|
|
53
|
+
from .viewer import Viewer
|
|
54
|
+
|
|
55
|
+
if isinstance(molecule_or_path, str):
|
|
56
|
+
mol = load(molecule_or_path)
|
|
57
|
+
kwargs.setdefault("source_path", molecule_or_path)
|
|
58
|
+
else:
|
|
59
|
+
mol = molecule_or_path
|
|
60
|
+
ensure_bonds(mol)
|
|
61
|
+
v = Viewer(mol, **kwargs)
|
|
62
|
+
v.widget.scene.camera.orbit(20, -15) # a pleasant default 3/4 view
|
|
63
|
+
v.run()
|
vimol/__main__.py
ADDED
vimol/app.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Command-line driver for vimol.
|
|
2
|
+
|
|
3
|
+
vimol # opens the bundled C60 demo (checkout only)
|
|
4
|
+
vimol file.pdb # interactive viewer (opens editable: a=append)
|
|
5
|
+
vimol file.xyz --spin # autospinning
|
|
6
|
+
vimol file.mol --render out.png --size 800x800
|
|
7
|
+
vimol file.pdb --kitty # emit one frame to stdout (for pipes/embeds)
|
|
8
|
+
vimol file.xyz --info # print structure info and exit
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from .parsers import load_all, SUPPORTED_EXTENSIONS
|
|
18
|
+
from .bonds import ensure_bonds
|
|
19
|
+
from .render import Style
|
|
20
|
+
from .scene import Scene
|
|
21
|
+
from . import kitty
|
|
22
|
+
from .molecule import Molecule
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_size(s: str):
|
|
26
|
+
if "x" in s.lower():
|
|
27
|
+
w, h = s.lower().split("x")
|
|
28
|
+
return int(w), int(h)
|
|
29
|
+
v = int(s)
|
|
30
|
+
return v, v
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_style(args) -> Style:
|
|
34
|
+
st = Style(representation=args.style)
|
|
35
|
+
if args.atom_scale is not None:
|
|
36
|
+
st.atom_scale = args.atom_scale
|
|
37
|
+
if args.bond_radius is not None:
|
|
38
|
+
st.bond_radius = args.bond_radius
|
|
39
|
+
if args.background is not None:
|
|
40
|
+
st.background = _parse_color(args.background)
|
|
41
|
+
st.transparent = False
|
|
42
|
+
if args.transparent:
|
|
43
|
+
st.transparent = True
|
|
44
|
+
if args.opaque:
|
|
45
|
+
st.transparent = False
|
|
46
|
+
if args.no_depth_cue:
|
|
47
|
+
st.depth_cue = 0.0
|
|
48
|
+
return st
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_color(s: str):
|
|
52
|
+
s = s.strip().lstrip("#")
|
|
53
|
+
if len(s) == 6:
|
|
54
|
+
return tuple(int(s[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
55
|
+
parts = s.split(",")
|
|
56
|
+
if len(parts) == 3:
|
|
57
|
+
return tuple(float(p) for p in parts)
|
|
58
|
+
raise argparse.ArgumentTypeError(f"bad color {s!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_parser() -> argparse.ArgumentParser:
|
|
62
|
+
p = argparse.ArgumentParser(
|
|
63
|
+
prog="vimol",
|
|
64
|
+
description="A terminal molecular viewer using the Kitty graphics protocol.",
|
|
65
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
66
|
+
epilog="Supported formats: " + ", ".join(SUPPORTED_EXTENSIONS),
|
|
67
|
+
)
|
|
68
|
+
p.add_argument("file", nargs="?", help="structure file (xyz/pdb/mol/sdf)")
|
|
69
|
+
p.add_argument("--style", default="ball_and_stick",
|
|
70
|
+
choices=["ball_and_stick", "spacefill", "licorice", "wireframe"])
|
|
71
|
+
p.add_argument("--backend", default="auto", choices=["auto", "cpu", "gl"],
|
|
72
|
+
help="rendering backend: numpy CPU raycaster, GPU (OpenGL, needs "
|
|
73
|
+
"vimol[gl]), or auto (GPU if available, else CPU)")
|
|
74
|
+
p.add_argument("--size", default="0x0", help="pixel size WxH for --render/--kitty (0=auto)")
|
|
75
|
+
p.add_argument("--supersample", type=int, default=2, help="anti-aliasing factor for stills")
|
|
76
|
+
p.add_argument("--rotate", nargs=2, type=float, metavar=("YAW", "PITCH"),
|
|
77
|
+
default=(20.0, -15.0), help="initial rotation in degrees")
|
|
78
|
+
p.add_argument("--spin", action="store_true", help="autospin in interactive mode")
|
|
79
|
+
p.add_argument("--render", metavar="PNG", help="render a still image to a PNG file and exit")
|
|
80
|
+
p.add_argument("--kitty", action="store_true", help="emit one frame to stdout as Kitty graphics and exit")
|
|
81
|
+
p.add_argument("--info", action="store_true", help="print structure info and exit")
|
|
82
|
+
p.add_argument("--frame", type=int, default=0, help="frame/model index for multi-model files")
|
|
83
|
+
p.add_argument("--atom-scale", type=float, default=None)
|
|
84
|
+
p.add_argument("--bond-radius", type=float, default=None)
|
|
85
|
+
p.add_argument("--background", type=str, default=None, help="hex or r,g,b background color (implies --opaque)")
|
|
86
|
+
p.add_argument("--transparent", action="store_true", help="transparent background (RGBA cutout)")
|
|
87
|
+
p.add_argument("--opaque", action="store_true", help="solid background (default for --render)")
|
|
88
|
+
p.add_argument("--no-depth-cue", action="store_true")
|
|
89
|
+
p.add_argument("--no-bonds", action="store_true", help="do not auto-perceive bonds")
|
|
90
|
+
p.add_argument("--bond-tolerance", type=float, default=0.45)
|
|
91
|
+
p.add_argument("--list-formats", action="store_true")
|
|
92
|
+
p.add_argument("--version", action="store_true")
|
|
93
|
+
return p
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _print_info(mol: Molecule):
|
|
97
|
+
print(f"name: {mol.name}")
|
|
98
|
+
print(f"formula: {mol.formula()}")
|
|
99
|
+
print(f"atoms: {mol.n_atoms}")
|
|
100
|
+
print(f"bonds: {len(mol.bonds)}")
|
|
101
|
+
ext = mol.radius_of_gyration_extent()
|
|
102
|
+
print(f"extent: {ext:.2f} A (max atom-centroid distance)")
|
|
103
|
+
from collections import Counter
|
|
104
|
+
comp = Counter(mol.symbols)
|
|
105
|
+
print("composition: " + ", ".join(f"{k}:{v}" for k, v in sorted(comp.items())))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _default_demo_path() -> Optional[str]:
|
|
109
|
+
"""Path to the bundled C60 demo, for `vimol` with no file argument.
|
|
110
|
+
|
|
111
|
+
Only resolvable from a checkout or editable install -- `examples/` sits
|
|
112
|
+
next to `src/`, not inside the installed package -- so a plain `vimol`
|
|
113
|
+
still falls back to --help rather than crashing on a real install.
|
|
114
|
+
"""
|
|
115
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
116
|
+
path = os.path.join(root, "examples", "c60.xyz")
|
|
117
|
+
return path if os.path.exists(path) else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main(argv: List[str] | None = None) -> int:
|
|
121
|
+
args = make_parser().parse_args(argv)
|
|
122
|
+
|
|
123
|
+
if args.version:
|
|
124
|
+
from . import __version__
|
|
125
|
+
print(f"vimol {__version__}")
|
|
126
|
+
return 0
|
|
127
|
+
if args.list_formats:
|
|
128
|
+
print("Supported formats: " + ", ".join(SUPPORTED_EXTENSIONS))
|
|
129
|
+
return 0
|
|
130
|
+
if not args.file:
|
|
131
|
+
args.file = _default_demo_path()
|
|
132
|
+
if not args.file:
|
|
133
|
+
make_parser().print_help()
|
|
134
|
+
return 1
|
|
135
|
+
if not os.path.exists(args.file):
|
|
136
|
+
print(f"error: no such file: {args.file}", file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
mols = load_all(args.file)
|
|
141
|
+
except Exception as e: # noqa: BLE001
|
|
142
|
+
print(f"error: failed to parse {args.file}: {e}", file=sys.stderr)
|
|
143
|
+
return 3
|
|
144
|
+
if not mols:
|
|
145
|
+
print("error: no molecules parsed", file=sys.stderr)
|
|
146
|
+
return 3
|
|
147
|
+
|
|
148
|
+
for m in mols:
|
|
149
|
+
if not args.no_bonds:
|
|
150
|
+
ensure_bonds(m, tolerance=args.bond_tolerance)
|
|
151
|
+
|
|
152
|
+
idx = max(0, min(args.frame, len(mols) - 1))
|
|
153
|
+
mol = mols[idx]
|
|
154
|
+
|
|
155
|
+
if args.info:
|
|
156
|
+
_print_info(mol)
|
|
157
|
+
if len(mols) > 1:
|
|
158
|
+
print(f"models: {len(mols)}")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
style = build_style(args)
|
|
162
|
+
w, h = _parse_size(args.size)
|
|
163
|
+
|
|
164
|
+
# -- still render to PNG ---------------------------------------------
|
|
165
|
+
if args.render:
|
|
166
|
+
if not w:
|
|
167
|
+
w, h = 900, 700
|
|
168
|
+
scene = Scene(mol, w, h, style=style, supersample=max(1, args.supersample),
|
|
169
|
+
backend=args.backend)
|
|
170
|
+
scene.camera.orbit(args.rotate[0], args.rotate[1])
|
|
171
|
+
scene.to_png(args.render)
|
|
172
|
+
print(f"wrote {args.render} ({w}x{h})")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
# -- single kitty frame to stdout ------------------------------------
|
|
176
|
+
if args.kitty:
|
|
177
|
+
if not w:
|
|
178
|
+
cols, rows, xpx, ypx = kitty.terminal_size_px(1)
|
|
179
|
+
cw, ch = kitty.cell_size_px(1)
|
|
180
|
+
w = int(min(cols, 60) * cw)
|
|
181
|
+
h = int(min(rows - 2, 30) * ch)
|
|
182
|
+
w, h = max(w, 200), max(h, 200)
|
|
183
|
+
scene = Scene(mol, w, h, style=style, supersample=max(1, args.supersample),
|
|
184
|
+
backend=args.backend)
|
|
185
|
+
scene.camera.orbit(args.rotate[0], args.rotate[1])
|
|
186
|
+
sys.stdout.write("\n")
|
|
187
|
+
sys.stdout.flush()
|
|
188
|
+
os.write(1, scene.to_kitty(move_cursor=True))
|
|
189
|
+
sys.stdout.write("\n")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
# -- interactive viewer ----------------------------------------------
|
|
193
|
+
if not sys.stdout.isatty():
|
|
194
|
+
print("error: interactive mode needs a terminal (use --render or --kitty)", file=sys.stderr)
|
|
195
|
+
return 4
|
|
196
|
+
if not kitty.supports_kitty():
|
|
197
|
+
print("warning: this terminal may not support the Kitty graphics protocol.",
|
|
198
|
+
file=sys.stderr)
|
|
199
|
+
print(" Set VIMOL_FORCE_KITTY=1 to try anyway, or use --render out.png.",
|
|
200
|
+
file=sys.stderr)
|
|
201
|
+
return 5
|
|
202
|
+
|
|
203
|
+
# interactive defaults to a terminal-matching transparent background
|
|
204
|
+
if not args.opaque and args.background is None:
|
|
205
|
+
style.transparent = True
|
|
206
|
+
|
|
207
|
+
from .viewer import Viewer
|
|
208
|
+
viewer = Viewer(mol, frames=mols, style=style, autospin=args.spin,
|
|
209
|
+
backend=args.backend, source_path=args.file, editable=True)
|
|
210
|
+
viewer.frame_index = idx
|
|
211
|
+
# apply initial rotation
|
|
212
|
+
viewer.widget.scene.camera.orbit(args.rotate[0], args.rotate[1])
|
|
213
|
+
viewer.run()
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
raise SystemExit(main())
|
vimol/arrows.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Per-atom vector field -> arrow geometry, shared by both render backends.
|
|
2
|
+
|
|
3
|
+
Each ``Molecule.vector_fields`` entry is a raw ``(N, 3)`` array of
|
|
4
|
+
view-independent directions/magnitudes; this module turns it into view-space
|
|
5
|
+
arrow segments (a cylindrical shaft + a conical head per arrow) that
|
|
6
|
+
``render.Renderer`` (CPU) and ``gl_adapter`` (GPU) both consume, so the arrow
|
|
7
|
+
geometry itself is defined once. Like ``render.py``, this module only depends
|
|
8
|
+
on ``camera.py``/``molecule.py`` -- no GL imports.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .camera import Camera
|
|
17
|
+
from .molecule import Molecule
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ArrowGeometry:
|
|
22
|
+
"""View-space arrow segments, split into shaft cylinders and head cones."""
|
|
23
|
+
shaft_a: np.ndarray # (M, 3) view space -- base (at the atom)
|
|
24
|
+
shaft_b: np.ndarray # (M, 3) view space -- where the head starts
|
|
25
|
+
shaft_radius: np.ndarray # (M,)
|
|
26
|
+
shaft_color: np.ndarray # (M, 3)
|
|
27
|
+
head_base: np.ndarray # (M, 3) view space -- wide end of the cone
|
|
28
|
+
head_apex: np.ndarray # (M, 3) view space -- arrow tip
|
|
29
|
+
head_radius: np.ndarray # (M,) -- cone base radius
|
|
30
|
+
head_color: np.ndarray # (M, 3)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def empty() -> "ArrowGeometry":
|
|
34
|
+
z3 = np.zeros((0, 3), np.float64)
|
|
35
|
+
z1 = np.zeros((0,), np.float64)
|
|
36
|
+
return ArrowGeometry(
|
|
37
|
+
shaft_a=z3.copy(), shaft_b=z3.copy(), shaft_radius=z1.copy(), shaft_color=z3.copy(),
|
|
38
|
+
head_base=z3.copy(), head_apex=z3.copy(), head_radius=z1.copy(), head_color=z3.copy(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_arrow_geometry(mol: Molecule, camera: Camera, view_pos=None) -> ArrowGeometry:
|
|
43
|
+
"""Convert ``mol.vector_fields`` into view-space arrow geometry under *camera*.
|
|
44
|
+
|
|
45
|
+
*view_pos* optionally supplies the atoms' already-computed view-space
|
|
46
|
+
positions (``camera.view_positions(mol.positions)``); the CPU renderer and
|
|
47
|
+
GL adapter both have it on hand, so passing it avoids re-running that
|
|
48
|
+
per-frame transform.
|
|
49
|
+
"""
|
|
50
|
+
if not mol.vector_fields or mol.n_atoms == 0:
|
|
51
|
+
return ArrowGeometry.empty()
|
|
52
|
+
|
|
53
|
+
view_pos = camera.view_positions(mol.positions) if view_pos is None else np.asarray(view_pos, float)
|
|
54
|
+
shaft_a, shaft_b, shaft_r, shaft_c = [], [], [], []
|
|
55
|
+
head_base, head_apex, head_r, head_c = [], [], [], []
|
|
56
|
+
|
|
57
|
+
for vf in mol.vector_fields:
|
|
58
|
+
vectors = np.asarray(vf.vectors, np.float64)
|
|
59
|
+
if vectors.shape != (mol.n_atoms, 3):
|
|
60
|
+
continue
|
|
61
|
+
mag = np.linalg.norm(vectors, axis=1)
|
|
62
|
+
keep = mag > 1e-9
|
|
63
|
+
if not keep.any():
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
view_vecs = camera.view_directions(vectors[keep])
|
|
67
|
+
base = view_pos[keep]
|
|
68
|
+
tip = base + view_vecs * vf.scale
|
|
69
|
+
frac = float(np.clip(vf.head_length_frac, 0.0, 1.0))
|
|
70
|
+
head_start = base + (tip - base) * (1.0 - frac)
|
|
71
|
+
|
|
72
|
+
n = base.shape[0]
|
|
73
|
+
color = np.asarray(vf.color, np.float64)
|
|
74
|
+
shaft_a.append(base)
|
|
75
|
+
shaft_b.append(head_start)
|
|
76
|
+
shaft_r.append(np.full(n, vf.radius))
|
|
77
|
+
shaft_c.append(np.tile(color, (n, 1)))
|
|
78
|
+
head_base.append(head_start)
|
|
79
|
+
head_apex.append(tip)
|
|
80
|
+
head_r.append(np.full(n, vf.radius * vf.head_scale))
|
|
81
|
+
head_c.append(np.tile(color, (n, 1)))
|
|
82
|
+
|
|
83
|
+
if not shaft_a:
|
|
84
|
+
return ArrowGeometry.empty()
|
|
85
|
+
|
|
86
|
+
return ArrowGeometry(
|
|
87
|
+
shaft_a=np.concatenate(shaft_a), shaft_b=np.concatenate(shaft_b),
|
|
88
|
+
shaft_radius=np.concatenate(shaft_r), shaft_color=np.concatenate(shaft_c),
|
|
89
|
+
head_base=np.concatenate(head_base), head_apex=np.concatenate(head_apex),
|
|
90
|
+
head_radius=np.concatenate(head_r), head_color=np.concatenate(head_c),
|
|
91
|
+
)
|
vimol/bonds.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Distance-based bond perception.
|
|
2
|
+
|
|
3
|
+
Two atoms are bonded when their separation is below the sum of covalent radii
|
|
4
|
+
plus a tolerance. A uniform grid (spatial hash) keeps this near-linear so it
|
|
5
|
+
scales to large structures instead of being O(N^2).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .molecule import Molecule
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def perceive_bonds(mol: Molecule, tolerance: float = 0.45, max_bonds_per_atom: int = 8) -> List[Tuple[int, int, int]]:
|
|
17
|
+
"""Return a bond list inferred from interatomic distances.
|
|
18
|
+
|
|
19
|
+
tolerance is added to the sum of covalent radii (angstrom).
|
|
20
|
+
"""
|
|
21
|
+
n = mol.n_atoms
|
|
22
|
+
if n < 2:
|
|
23
|
+
return []
|
|
24
|
+
pos = mol.positions
|
|
25
|
+
cov = mol.covalent_radii()
|
|
26
|
+
max_r = float(cov.max())
|
|
27
|
+
cell = max(2 * max_r + tolerance, 1.0)
|
|
28
|
+
|
|
29
|
+
# Spatial hash: map each atom to an integer grid cell.
|
|
30
|
+
mins = pos.min(axis=0)
|
|
31
|
+
ijk = np.floor((pos - mins) / cell).astype(np.int64)
|
|
32
|
+
buckets: dict = {}
|
|
33
|
+
for a in range(n):
|
|
34
|
+
buckets.setdefault(tuple(ijk[a]), []).append(a)
|
|
35
|
+
|
|
36
|
+
bonds: List[Tuple[int, int, int]] = []
|
|
37
|
+
counts = np.zeros(n, dtype=np.int32)
|
|
38
|
+
seen = set()
|
|
39
|
+
neigh = [(-1, 0, 1)] * 3
|
|
40
|
+
from itertools import product
|
|
41
|
+
offsets = list(product(*neigh))
|
|
42
|
+
|
|
43
|
+
for a in range(n):
|
|
44
|
+
base = ijk[a]
|
|
45
|
+
cand: List[int] = []
|
|
46
|
+
for off in offsets:
|
|
47
|
+
key = (base[0] + off[0], base[1] + off[1], base[2] + off[2])
|
|
48
|
+
b = buckets.get(key)
|
|
49
|
+
if b:
|
|
50
|
+
cand.extend(b)
|
|
51
|
+
pa = pos[a]
|
|
52
|
+
ra = cov[a]
|
|
53
|
+
for b in cand:
|
|
54
|
+
if b <= a:
|
|
55
|
+
continue
|
|
56
|
+
cutoff = ra + cov[b] + tolerance
|
|
57
|
+
d = pa - pos[b]
|
|
58
|
+
if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= cutoff * cutoff:
|
|
59
|
+
# Reject absurdly short contacts (overlapping/duplicate atoms).
|
|
60
|
+
dist2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
|
|
61
|
+
if dist2 < 0.16: # < 0.4 A
|
|
62
|
+
continue
|
|
63
|
+
if counts[a] >= max_bonds_per_atom or counts[b] >= max_bonds_per_atom:
|
|
64
|
+
continue
|
|
65
|
+
key = (a, b)
|
|
66
|
+
if key in seen:
|
|
67
|
+
continue
|
|
68
|
+
seen.add(key)
|
|
69
|
+
bonds.append((a, b, 1))
|
|
70
|
+
counts[a] += 1
|
|
71
|
+
counts[b] += 1
|
|
72
|
+
return bonds
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_bonds(mol: Molecule, tolerance: float = 0.45) -> Molecule:
|
|
76
|
+
"""Populate mol.bonds if empty. Returns the molecule for chaining."""
|
|
77
|
+
if not mol.bonds and mol.n_atoms > 1:
|
|
78
|
+
mol.bonds = perceive_bonds(mol, tolerance=tolerance)
|
|
79
|
+
return mol
|
vimol/camera.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Orthographic trackball camera.
|
|
2
|
+
|
|
3
|
+
The camera keeps a 3x3 rotation matrix (world -> view), a look-at center, and a
|
|
4
|
+
zoom expressed as pixels-per-angstrom. View space is right-handed with +x
|
|
5
|
+
right, +y up, +z toward the viewer, so the renderer keeps the fragment with the
|
|
6
|
+
largest z (nearest the camera).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize(v: np.ndarray) -> np.ndarray:
|
|
14
|
+
n = np.linalg.norm(v)
|
|
15
|
+
return v / n if n > 1e-12 else v
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rotation_from_axis_angle(axis: np.ndarray, angle: float) -> np.ndarray:
|
|
19
|
+
axis = _normalize(np.asarray(axis, float))
|
|
20
|
+
x, y, z = axis
|
|
21
|
+
c = np.cos(angle)
|
|
22
|
+
s = np.sin(angle)
|
|
23
|
+
t = 1.0 - c
|
|
24
|
+
return np.array([
|
|
25
|
+
[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
|
|
26
|
+
[t * x * y + s * z, t * y * y + c, t * y * z - s * x],
|
|
27
|
+
[t * x * z - s * y, t * y * z + s * x, t * z * z + c],
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Camera:
|
|
32
|
+
def __init__(self, center=None, extent: float = 5.0):
|
|
33
|
+
self.center = np.zeros(3) if center is None else np.array(center, float)
|
|
34
|
+
self.rotation = np.eye(3)
|
|
35
|
+
# zoom is pixels per angstrom; set by fit()
|
|
36
|
+
self.zoom = 40.0
|
|
37
|
+
self.extent = float(extent)
|
|
38
|
+
# pan offset in screen pixels
|
|
39
|
+
self.pan = np.zeros(2)
|
|
40
|
+
|
|
41
|
+
# -- framing ----------------------------------------------------------
|
|
42
|
+
def fit(self, width: int, height: int, extent: float, margin: float = 1.15) -> None:
|
|
43
|
+
"""Choose a zoom so a sphere of *extent* angstrom fills the viewport."""
|
|
44
|
+
self.extent = float(extent)
|
|
45
|
+
half = max(min(width, height) * 0.5, 1.0)
|
|
46
|
+
self.zoom = half / (extent * margin)
|
|
47
|
+
self.pan = np.zeros(2)
|
|
48
|
+
|
|
49
|
+
# -- interaction ------------------------------------------------------
|
|
50
|
+
def orbit(self, dx: float, dy: float, speed: float = 0.01) -> None:
|
|
51
|
+
"""Rotate about screen axes. dx/dy are in pixels."""
|
|
52
|
+
if dx:
|
|
53
|
+
self.rotation = rotation_from_axis_angle([0, 1, 0], dx * speed) @ self.rotation
|
|
54
|
+
if dy:
|
|
55
|
+
self.rotation = rotation_from_axis_angle([1, 0, 0], dy * speed) @ self.rotation
|
|
56
|
+
|
|
57
|
+
def roll(self, angle: float) -> None:
|
|
58
|
+
self.rotation = rotation_from_axis_angle([0, 0, 1], angle) @ self.rotation
|
|
59
|
+
|
|
60
|
+
def zoom_by(self, factor: float) -> None:
|
|
61
|
+
self.zoom = float(np.clip(self.zoom * factor, 1.0, 1e5))
|
|
62
|
+
|
|
63
|
+
def pan_by(self, dx: float, dy: float) -> None:
|
|
64
|
+
self.pan = self.pan + np.array([dx, dy], float)
|
|
65
|
+
|
|
66
|
+
def reset(self) -> None:
|
|
67
|
+
self.rotation = np.eye(3)
|
|
68
|
+
self.pan = np.zeros(2)
|
|
69
|
+
|
|
70
|
+
# -- transforms -------------------------------------------------------
|
|
71
|
+
def view_positions(self, positions: np.ndarray) -> np.ndarray:
|
|
72
|
+
"""World coordinates -> view-space coordinates (angstrom)."""
|
|
73
|
+
return (positions - self.center) @ self.rotation.T
|
|
74
|
+
|
|
75
|
+
def view_directions(self, directions: np.ndarray) -> np.ndarray:
|
|
76
|
+
"""World -> view space for free vectors (rotation only, no translation)."""
|
|
77
|
+
return np.asarray(directions, float) @ self.rotation.T
|