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 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
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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