vimol 0.1.1__tar.gz → 0.1.2__tar.gz
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-0.1.1/src/vimol.egg-info → vimol-0.1.2}/PKG-INFO +7 -2
- {vimol-0.1.1 → vimol-0.1.2}/README.md +5 -1
- {vimol-0.1.1 → vimol-0.1.2}/pyproject.toml +5 -1
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/__init__.py +1 -1
- vimol-0.1.2/src/vimol/_render_fast.py +418 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/app.py +20 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/editor.py +37 -28
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/elements.py +23 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/gl_adapter.py +30 -5
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/gl_render.py +107 -55
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/input.py +18 -1
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/kitty.py +226 -30
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/xyz.py +1 -1
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/render.py +206 -63
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/scene.py +69 -13
- vimol-0.1.2/src/vimol/structures.py +398 -0
- vimol-0.1.2/src/vimol/theme.py +142 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/viewer.py +997 -73
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/widget.py +151 -27
- {vimol-0.1.1 → vimol-0.1.2/src/vimol.egg-info}/PKG-INFO +7 -2
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/SOURCES.txt +6 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/requires.txt +1 -0
- {vimol-0.1.1 → vimol-0.1.2}/tests/test_editing.py +283 -14
- {vimol-0.1.1 → vimol-0.1.2}/tests/test_gl_render.py +196 -0
- vimol-0.1.2/tests/test_render_fast.py +208 -0
- vimol-0.1.2/tests/test_scene_structures.py +357 -0
- vimol-0.1.2/tests/test_structures.py +421 -0
- vimol-0.1.2/tests/test_vimol.py +2632 -0
- vimol-0.1.1/tests/test_vimol.py +0 -552
- {vimol-0.1.1 → vimol-0.1.2}/LICENSE +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/setup.cfg +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/__main__.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/arrows.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/bonds.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/camera.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/molecule.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/__init__.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/mol.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/pdb.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/periodic_table.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/py.typed +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol/templates.py +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/dependency_links.txt +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/entry_points.txt +0 -0
- {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vimol
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A terminal molecular viewer and editor using the Kitty graphics protocol
|
|
5
5
|
Author: vimol contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: numpy>=1.20
|
|
29
29
|
Requires-Dist: moderngl>=5.6
|
|
30
|
+
Requires-Dist: numba>=0.59
|
|
30
31
|
Provides-Extra: test
|
|
31
32
|
Requires-Dist: pytest>=7; extra == "test"
|
|
32
33
|
Dynamic: license-file
|
|
@@ -54,7 +55,11 @@ available. MIT licensed.
|
|
|
54
55
|

|
|
55
56
|
|
|
56
57
|
`vimol` with no file opens this bundled C60 demo. Drag to rotate, scroll to
|
|
57
|
-
zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals
|
|
58
|
+
zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals
|
|
59
|
+
— with more than one structure loaded, a live column tracks it in a
|
|
60
|
+
comparison table next to the structure list, evaluated for every loaded
|
|
61
|
+
structure at once; move on to a different measurement (or a different frame)
|
|
62
|
+
and it locks in as its own column (click a column's `×` to remove it).
|
|
58
63
|
Editing is on by default: `a` to append (grow fragments, swap elements — the
|
|
59
64
|
status-bar pills pick the element and geometry), option-drag to draw bonds,
|
|
60
65
|
`x` to delete, `c` to relax clashes, `u` to undo, `s` to save. `?` lists
|
|
@@ -21,7 +21,11 @@ available. MIT licensed.
|
|
|
21
21
|

|
|
22
22
|
|
|
23
23
|
`vimol` with no file opens this bundled C60 demo. Drag to rotate, scroll to
|
|
24
|
-
zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals
|
|
24
|
+
zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals
|
|
25
|
+
— with more than one structure loaded, a live column tracks it in a
|
|
26
|
+
comparison table next to the structure list, evaluated for every loaded
|
|
27
|
+
structure at once; move on to a different measurement (or a different frame)
|
|
28
|
+
and it locks in as its own column (click a column's `×` to remove it).
|
|
25
29
|
Editing is on by default: `a` to append (grow fragments, swap elements — the
|
|
26
30
|
status-bar pills pick the element and geometry), option-drag to draw bonds,
|
|
27
31
|
`x` to delete, `c` to relax clashes, `u` to undo, `s` to save. `?` lists
|
|
@@ -37,7 +37,11 @@ classifiers = [
|
|
|
37
37
|
# moderngl is a hard dependency (~120 KB of wheels): the GL backend is always
|
|
38
38
|
# available, and `backend="auto"` still falls back to the CPU raycaster at
|
|
39
39
|
# runtime wherever no OpenGL context can be created (headless boxes, clusters).
|
|
40
|
-
|
|
40
|
+
# numba compiles that CPU raycaster's inner loop (_render_fast) to machine
|
|
41
|
+
# code: ~10-25x faster CPU rendering, bit-identical output. render.py still
|
|
42
|
+
# tolerates numba being uninstalled/uncompilable (falls back to the pure-numpy
|
|
43
|
+
# path) since it's a best-effort JIT, not because it's meant to be omitted.
|
|
44
|
+
dependencies = ["numpy>=1.20", "moderngl>=5.6", "numba>=0.59"]
|
|
41
45
|
|
|
42
46
|
[project.optional-dependencies]
|
|
43
47
|
test = ["pytest>=7"]
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Numba-compiled inner loop for the CPU raycaster.
|
|
2
|
+
|
|
3
|
+
The numpy renderer (``render.py``) vectorizes per primitive over its
|
|
4
|
+
screen-space bounding box: every sphere/cylinder costs ~40 full-box array
|
|
5
|
+
passes, most of them over pixels that fail the z-test, plus thousands of
|
|
6
|
+
per-call overheads per frame. That is a structural ceiling -- measured
|
|
7
|
+
~130 ms/frame for C60 at 2560x1440.
|
|
8
|
+
|
|
9
|
+
This module compiles the same impostor math to machine code (per-pixel loops
|
|
10
|
+
with early z-rejection *before* shading, the way PyMOL's C++ ray tracer and
|
|
11
|
+
every other compiled rasterizer do it), which measures 10-25x faster. numba
|
|
12
|
+
is a hard dependency (see pyproject.toml), but this module still degrades
|
|
13
|
+
gracefully if it's ever missing or fails to compile on some platform:
|
|
14
|
+
``ready()`` stays False and ``render.py``'s numpy path runs unchanged.
|
|
15
|
+
|
|
16
|
+
BIT-PARITY CONTRACT: the kernels below mirror render.py's numpy operations
|
|
17
|
+
one-for-one -- same dtypes (float32 per-pixel math, float64 screen setup),
|
|
18
|
+
same operation order and association, same scalar casts (numpy converts a
|
|
19
|
+
python-float scalar to float32 when it meets a float32 array; typed numpy
|
|
20
|
+
float64 scalars promote instead -- both cases are reproduced explicitly).
|
|
21
|
+
``tests/test_render_fast.py`` verifies the output is bit-identical to the
|
|
22
|
+
numpy path -- run it after touching either side of the contract.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import threading
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
# numba's disk cache (cache=True below) defaults to __pycache__ next to this
|
|
32
|
+
# source file -- churn a sync client (Dropbox) then uploads on every
|
|
33
|
+
# recompile. Redirect it to the user cache dir before numba is first
|
|
34
|
+
# imported (the env var is read at numba import; if some other package
|
|
35
|
+
# imported numba already, this is a harmless no-op and the default location
|
|
36
|
+
# applies). setdefault, so an explicit NUMBA_CACHE_DIR always wins.
|
|
37
|
+
os.environ.setdefault(
|
|
38
|
+
"NUMBA_CACHE_DIR",
|
|
39
|
+
os.path.join(os.path.expanduser("~"), ".cache", "vimol", "numba"))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from numba import njit, prange
|
|
43
|
+
_HAVE_NUMBA = True
|
|
44
|
+
except Exception: # hard dependency, but degrade gracefully
|
|
45
|
+
_HAVE_NUMBA = False
|
|
46
|
+
|
|
47
|
+
def njit(*args, **kwargs): # stub so module import never fails
|
|
48
|
+
def wrap(fn):
|
|
49
|
+
return fn
|
|
50
|
+
return wrap if not (args and callable(args[0])) else args[0]
|
|
51
|
+
|
|
52
|
+
def prange(*args):
|
|
53
|
+
return range(*args)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# 0 = cold, 1 = compiling, 2 = ready, 3 = unavailable/failed
|
|
57
|
+
_state = 0
|
|
58
|
+
_lock = threading.Lock()
|
|
59
|
+
# Set once the one compile attempt (by whichever caller -- warm_async's
|
|
60
|
+
# thread or a direct warm_sync() caller -- got there first) finishes, success
|
|
61
|
+
# or failure. Callers that lost the race to start it wait on this instead of
|
|
62
|
+
# calling the compile routine themselves: numba's dispatcher is not safe to
|
|
63
|
+
# enter from two threads at once for the same not-yet-compiled function --
|
|
64
|
+
# observed reliably (SIGABRT/SIGSEGV) when a second, unsynchronized caller
|
|
65
|
+
# invoked the compile while warm_async's background thread was mid-compile.
|
|
66
|
+
_done = threading.Event()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def available() -> bool:
|
|
70
|
+
"""numba importable on this machine (kernel may still be compiling)."""
|
|
71
|
+
return _HAVE_NUMBA
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ready() -> bool:
|
|
75
|
+
"""Kernel compiled and safe to call (first call triggers compilation)."""
|
|
76
|
+
return _state == 2
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def warm_async() -> None:
|
|
80
|
+
"""Compile the kernel in a daemon thread so the first frames don't stall.
|
|
81
|
+
|
|
82
|
+
JIT compilation takes ~1-3 s once per process (cached on disk afterwards
|
|
83
|
+
where the package dir is writable). Frames rendered while this runs use
|
|
84
|
+
the numpy path; the renderer switches over the moment ready() flips.
|
|
85
|
+
"""
|
|
86
|
+
if not _HAVE_NUMBA:
|
|
87
|
+
return
|
|
88
|
+
if not _claim():
|
|
89
|
+
return
|
|
90
|
+
threading.Thread(target=_run_once, daemon=True).start()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def warm_sync(timeout: float | None = None) -> bool:
|
|
94
|
+
"""Force the kernel to be ready (or known unavailable) before returning.
|
|
95
|
+
|
|
96
|
+
If nobody has started the compile yet, this thread does it (blocking).
|
|
97
|
+
If warm_async's background thread already claimed it, this waits for
|
|
98
|
+
that thread to finish instead of compiling again -- see the note on
|
|
99
|
+
``_done`` above for why a second concurrent attempt is unsafe. Returns
|
|
100
|
+
``ready()``. Used by callers (tests, the benchmark scripts) that need a
|
|
101
|
+
definite answer and can't rely on warm_async's fire-and-forget timing.
|
|
102
|
+
"""
|
|
103
|
+
if not _HAVE_NUMBA:
|
|
104
|
+
return False
|
|
105
|
+
if _claim():
|
|
106
|
+
_run_once()
|
|
107
|
+
else:
|
|
108
|
+
_done.wait(timeout)
|
|
109
|
+
return ready()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _claim() -> bool:
|
|
113
|
+
"""Atomically move state 0 -> 1. True for the one caller that must
|
|
114
|
+
actually run the compile; False for everyone else (already compiling,
|
|
115
|
+
ready, or failed)."""
|
|
116
|
+
global _state
|
|
117
|
+
with _lock:
|
|
118
|
+
if _state != 0:
|
|
119
|
+
return False
|
|
120
|
+
_state = 1
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run_once() -> None:
|
|
125
|
+
global _state
|
|
126
|
+
try:
|
|
127
|
+
# (8, 8, 4) rgba: (H, W, 3) and (H, W, 4) C-contiguous buffers share
|
|
128
|
+
# one numba type (uint8 3d C), so this single call compiles the only
|
|
129
|
+
# signature both opaque and transparent frames use.
|
|
130
|
+
color = np.zeros((8, 8, 4), np.uint8)
|
|
131
|
+
zbuf = np.full((8, 8), -np.inf, np.float32)
|
|
132
|
+
bands = np.array([[0, 8]], np.int64)
|
|
133
|
+
sx = np.array([4.0], np.float64)
|
|
134
|
+
sy = np.array([4.0], np.float64)
|
|
135
|
+
sz = np.array([0.0], np.float64)
|
|
136
|
+
radii = np.array([0.5], np.float64)
|
|
137
|
+
acol = np.array([[0.5, 0.5, 0.5]], np.float32)
|
|
138
|
+
aflat = np.zeros(1, np.uint8)
|
|
139
|
+
order = np.array([0], np.int64)
|
|
140
|
+
barr = np.zeros((0, 14), np.float64)
|
|
141
|
+
bcol = np.zeros((0, 6), np.float32)
|
|
142
|
+
bflat = np.zeros(0, np.uint8)
|
|
143
|
+
render_frame(color, zbuf, True, bands,
|
|
144
|
+
sx, sy, sz, radii, acol, aflat, order,
|
|
145
|
+
barr, bcol, bflat,
|
|
146
|
+
10.0, 4.0, 4.0,
|
|
147
|
+
0.3, 0.5, 0.7, -0.3, -0.5, 0.6, 0.4, 0.5, 0.7,
|
|
148
|
+
0.28, 0.25, 0.55, 0.55, -1.0, 2.0, 5)
|
|
149
|
+
_state = 2
|
|
150
|
+
except Exception:
|
|
151
|
+
_state = 3
|
|
152
|
+
finally:
|
|
153
|
+
_done.set()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# compiled kernels
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
@njit(inline="always")
|
|
160
|
+
def _shade_pixel(color, y, x, alpha_on, depth, nx, ny, nz,
|
|
161
|
+
ar, ag, ab, flat,
|
|
162
|
+
l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
|
|
163
|
+
ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares):
|
|
164
|
+
"""Shade one winning fragment and write color/alpha/depth.
|
|
165
|
+
|
|
166
|
+
``color`` is (H, W, 3) opaque or (H, W, 4) rgba; ``alpha_on`` means the
|
|
167
|
+
4th channel exists and coverage is written there. Mirrors
|
|
168
|
+
render.shade_write's per-element op sequence (see module docstring).
|
|
169
|
+
nx/ny/nz/ar..ab/depth are float32; the light scalars are float64
|
|
170
|
+
python-side values that numpy would have cast to float32 at each use --
|
|
171
|
+
the casts here make that explicit.
|
|
172
|
+
"""
|
|
173
|
+
if flat:
|
|
174
|
+
if depth_cue > 0.0:
|
|
175
|
+
fog = (depth - np.float32(zmin)) * np.float32(1.0 / zspan)
|
|
176
|
+
if fog < np.float32(0.0):
|
|
177
|
+
fog = np.float32(0.0)
|
|
178
|
+
if fog > np.float32(1.0):
|
|
179
|
+
fog = np.float32(1.0)
|
|
180
|
+
fog *= np.float32(depth_cue)
|
|
181
|
+
fog += np.float32(1.0 - depth_cue)
|
|
182
|
+
rr = ar * fog
|
|
183
|
+
rr *= np.float32(255.0)
|
|
184
|
+
gg = ag * fog
|
|
185
|
+
gg *= np.float32(255.0)
|
|
186
|
+
bb = ab * fog
|
|
187
|
+
bb *= np.float32(255.0)
|
|
188
|
+
else:
|
|
189
|
+
rr = min(ar, np.float32(1.0)) * np.float32(255.0)
|
|
190
|
+
gg = min(ag, np.float32(1.0)) * np.float32(255.0)
|
|
191
|
+
bb = min(ab, np.float32(1.0)) * np.float32(255.0)
|
|
192
|
+
else:
|
|
193
|
+
diff = nx * np.float32(l0) + ny * np.float32(l1) + nz * np.float32(l2)
|
|
194
|
+
if diff < np.float32(0.0):
|
|
195
|
+
diff = np.float32(0.0)
|
|
196
|
+
dfill = nx * np.float32(f0) + ny * np.float32(f1) + nz * np.float32(f2)
|
|
197
|
+
if dfill < np.float32(0.0):
|
|
198
|
+
dfill = np.float32(0.0)
|
|
199
|
+
dh = nx * np.float32(hv0) + ny * np.float32(hv1) + nz * np.float32(hv2)
|
|
200
|
+
if dh < np.float32(0.0):
|
|
201
|
+
dh = np.float32(0.0)
|
|
202
|
+
# _fast_pow for a power-of-two exponent: x*x then repeated squaring,
|
|
203
|
+
# identical rounding sequence to the numpy version.
|
|
204
|
+
spec = dh
|
|
205
|
+
for _ in range(n_squares):
|
|
206
|
+
spec *= spec
|
|
207
|
+
spec *= np.float32(spec_w)
|
|
208
|
+
diff += np.float32(ambient)
|
|
209
|
+
dfill *= np.float32(fill_w)
|
|
210
|
+
diff += dfill
|
|
211
|
+
if depth_cue > 0.0:
|
|
212
|
+
fog = (depth - np.float32(zmin)) * np.float32(1.0 / zspan)
|
|
213
|
+
if fog < np.float32(0.0):
|
|
214
|
+
fog = np.float32(0.0)
|
|
215
|
+
if fog > np.float32(1.0):
|
|
216
|
+
fog = np.float32(1.0)
|
|
217
|
+
fog *= np.float32(depth_cue)
|
|
218
|
+
fog += np.float32(1.0 - depth_cue)
|
|
219
|
+
diff *= fog
|
|
220
|
+
spec *= fog
|
|
221
|
+
rr = ar * diff
|
|
222
|
+
rr += spec
|
|
223
|
+
gg = ag * diff
|
|
224
|
+
gg += spec
|
|
225
|
+
bb = ab * diff
|
|
226
|
+
bb += spec
|
|
227
|
+
if rr > np.float32(1.0):
|
|
228
|
+
rr = np.float32(1.0)
|
|
229
|
+
if gg > np.float32(1.0):
|
|
230
|
+
gg = np.float32(1.0)
|
|
231
|
+
if bb > np.float32(1.0):
|
|
232
|
+
bb = np.float32(1.0)
|
|
233
|
+
rr *= np.float32(255.0)
|
|
234
|
+
gg *= np.float32(255.0)
|
|
235
|
+
bb *= np.float32(255.0)
|
|
236
|
+
# np.copyto(..., casting="unsafe") / setitem float->uint8 truncates.
|
|
237
|
+
color[y, x, 0] = np.uint8(rr)
|
|
238
|
+
color[y, x, 1] = np.uint8(gg)
|
|
239
|
+
color[y, x, 2] = np.uint8(bb)
|
|
240
|
+
if alpha_on:
|
|
241
|
+
color[y, x, 3] = 255
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@njit(parallel=True, cache=True)
|
|
246
|
+
def _render_kernel(color, zbuf, alpha_on, bands,
|
|
247
|
+
sx, sy, sz, radii, acol, aflat, order,
|
|
248
|
+
barr, bcol, bflat,
|
|
249
|
+
zoom, ox_s, oy_s,
|
|
250
|
+
l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
|
|
251
|
+
ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares):
|
|
252
|
+
"""Raycast all bonds (list order) then all atoms (far-to-near order)
|
|
253
|
+
into their bands. Bands own disjoint rows, so prange is race-free; the
|
|
254
|
+
z-test makes per-pixel results order-independent up to the same ties the
|
|
255
|
+
numpy path resolves identically (same primitive order per band)."""
|
|
256
|
+
H, W = zbuf.shape
|
|
257
|
+
inv_zoom = 1.0 / zoom
|
|
258
|
+
iz32 = np.float32(1.0 / zoom)
|
|
259
|
+
niz32 = np.float32(-1.0 / zoom)
|
|
260
|
+
izz32 = np.float32(inv_zoom * inv_zoom)
|
|
261
|
+
n_bonds = barr.shape[0]
|
|
262
|
+
|
|
263
|
+
for band in prange(bands.shape[0]):
|
|
264
|
+
y_lo = bands[band, 0]
|
|
265
|
+
y_hi = bands[band, 1]
|
|
266
|
+
|
|
267
|
+
# -- bonds: capped-cylinder impostors (render._draw_cylinder_segment)
|
|
268
|
+
for k in range(n_bonds):
|
|
269
|
+
ax = barr[k, 0]
|
|
270
|
+
ay = barr[k, 1]
|
|
271
|
+
az = barr[k, 2]
|
|
272
|
+
ux = barr[k, 3]
|
|
273
|
+
uy = barr[k, 4]
|
|
274
|
+
uz = barr[k, 5]
|
|
275
|
+
L = barr[k, 6]
|
|
276
|
+
a2 = barr[k, 7]
|
|
277
|
+
radius = barr[k, 8]
|
|
278
|
+
sax = barr[k, 9]
|
|
279
|
+
say = barr[k, 10]
|
|
280
|
+
sbx = barr[k, 11]
|
|
281
|
+
sby = barr[k, 12]
|
|
282
|
+
srb = barr[k, 13]
|
|
283
|
+
if L < 1e-6 or radius <= 0.0:
|
|
284
|
+
continue
|
|
285
|
+
x0 = max(int(np.floor(min(sax, sbx) - srb)), 0)
|
|
286
|
+
x1 = min(int(np.ceil(max(sax, sbx) + srb)) + 1, W)
|
|
287
|
+
y0 = max(int(np.floor(min(say, sby) - srb)), y_lo)
|
|
288
|
+
y1 = min(int(np.ceil(max(say, sby) + srb)) + 1, y_hi)
|
|
289
|
+
if x0 >= x1 or y0 >= y1:
|
|
290
|
+
continue
|
|
291
|
+
ux32 = np.float32(ux)
|
|
292
|
+
uy32 = np.float32(uy)
|
|
293
|
+
uz32 = np.float32(uz)
|
|
294
|
+
b2c32 = np.float32(-2.0 * uz)
|
|
295
|
+
a2x4_32 = np.float32(a2 * 4.0)
|
|
296
|
+
half_a2_32 = np.float32(0.5 / a2) if abs(a2) >= 1e-9 else np.float32(0.0)
|
|
297
|
+
parallel = abs(a2) < 1e-9
|
|
298
|
+
inv_r32 = np.float32(1.0 / radius)
|
|
299
|
+
r2_32 = np.float32(radius * radius)
|
|
300
|
+
half_L32 = np.float32(0.5 * L)
|
|
301
|
+
L32 = np.float32(L)
|
|
302
|
+
az32 = np.float32(az)
|
|
303
|
+
ey_off32 = np.float32(oy_s / zoom - ay)
|
|
304
|
+
ox32 = np.float32(ox_s)
|
|
305
|
+
ax32 = np.float32(ax)
|
|
306
|
+
flat = bflat[k]
|
|
307
|
+
car, cag, cab = bcol[k, 0], bcol[k, 1], bcol[k, 2]
|
|
308
|
+
cbr, cbg, cbb = bcol[k, 3], bcol[k, 4], bcol[k, 5]
|
|
309
|
+
for y in range(y0, y1):
|
|
310
|
+
ey = np.float32(y) * niz32 + ey_off32
|
|
311
|
+
eyey = ey * ey
|
|
312
|
+
eyuy = ey * uy32
|
|
313
|
+
for x in range(x0, x1):
|
|
314
|
+
ex = (np.float32(x) - ox32) * iz32 - ax32
|
|
315
|
+
A0 = ex * ux32 + eyuy
|
|
316
|
+
c2 = ex * ex + eyey - A0 * A0 - r2_32
|
|
317
|
+
if parallel:
|
|
318
|
+
if c2 > np.float32(0.0):
|
|
319
|
+
continue
|
|
320
|
+
s = np.float32(0.0)
|
|
321
|
+
else:
|
|
322
|
+
b2 = A0 * b2c32
|
|
323
|
+
disc = b2 * b2 - a2x4_32 * c2
|
|
324
|
+
if disc < np.float32(0.0):
|
|
325
|
+
continue
|
|
326
|
+
sq = np.sqrt(disc)
|
|
327
|
+
s = (sq - b2) * half_a2_32
|
|
328
|
+
zview = s + az32
|
|
329
|
+
t = A0 + s * uz32
|
|
330
|
+
if t < np.float32(0.0) or t > L32:
|
|
331
|
+
continue
|
|
332
|
+
if zview <= zbuf[y, x]:
|
|
333
|
+
continue
|
|
334
|
+
nx = (ex - t * ux32) * inv_r32
|
|
335
|
+
ny = (ey - t * uy32) * inv_r32
|
|
336
|
+
nz = (s - t * uz32) * inv_r32
|
|
337
|
+
if t < half_L32:
|
|
338
|
+
ar, ag, ab = car, cag, cab
|
|
339
|
+
else:
|
|
340
|
+
ar, ag, ab = cbr, cbg, cbb
|
|
341
|
+
zbuf[y, x] = zview
|
|
342
|
+
_shade_pixel(color, y, x, alpha_on, zview, nx, ny, nz,
|
|
343
|
+
ar, ag, ab, flat,
|
|
344
|
+
l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
|
|
345
|
+
ambient, fill_w, spec_w, depth_cue, zmin, zspan,
|
|
346
|
+
n_squares)
|
|
347
|
+
|
|
348
|
+
# -- atoms: sphere impostors (render.draw_band's atom loop)
|
|
349
|
+
for oi in range(order.shape[0]):
|
|
350
|
+
a = order[oi]
|
|
351
|
+
r = radii[a]
|
|
352
|
+
if r <= 0.0:
|
|
353
|
+
continue
|
|
354
|
+
sr = r * zoom
|
|
355
|
+
if sr < 0.5:
|
|
356
|
+
sr = 0.5
|
|
357
|
+
cx = sx[a]
|
|
358
|
+
cy = sy[a]
|
|
359
|
+
cz = sz[a]
|
|
360
|
+
x0 = max(int(np.floor(cx - sr)), 0)
|
|
361
|
+
x1 = min(int(np.ceil(cx + sr)) + 1, W)
|
|
362
|
+
y0 = max(int(np.floor(cy - sr)), y_lo)
|
|
363
|
+
y1 = min(int(np.ceil(cy + sr)) + 1, y_hi)
|
|
364
|
+
if x0 >= x1 or y0 >= y1:
|
|
365
|
+
continue
|
|
366
|
+
cx32 = np.float32(cx)
|
|
367
|
+
cy32 = np.float32(cy)
|
|
368
|
+
cz32 = np.float32(cz)
|
|
369
|
+
sr2 = sr * sr # float64: d2 <= sr2 promotes
|
|
370
|
+
rr32 = np.float32(r * r)
|
|
371
|
+
inv_r = 1.0 / r
|
|
372
|
+
nxy32 = np.float32(inv_zoom * inv_r)
|
|
373
|
+
nz32 = np.float32(inv_r)
|
|
374
|
+
flat = aflat[a]
|
|
375
|
+
ar = acol[a, 0]
|
|
376
|
+
ag = acol[a, 1]
|
|
377
|
+
ab = acol[a, 2]
|
|
378
|
+
for y in range(y0, y1):
|
|
379
|
+
dyr = np.float32(y) - cy32
|
|
380
|
+
dyr2 = dyr * dyr
|
|
381
|
+
ny = dyr * np.float32(-inv_zoom * inv_r)
|
|
382
|
+
for x in range(x0, x1):
|
|
383
|
+
dxr = np.float32(x) - cx32
|
|
384
|
+
d2 = dxr * dxr + dyr2
|
|
385
|
+
# numpy compares float32 d2 against the float64 scalar
|
|
386
|
+
# sr*sr (typed numpy scalar promotes); mirror exactly.
|
|
387
|
+
if not (d2 <= sr2):
|
|
388
|
+
continue
|
|
389
|
+
h2 = rr32 - d2 * izz32
|
|
390
|
+
if h2 < np.float32(0.0):
|
|
391
|
+
h2 = np.float32(0.0)
|
|
392
|
+
hgt = np.sqrt(h2)
|
|
393
|
+
depth = hgt + cz32
|
|
394
|
+
if depth <= zbuf[y, x]:
|
|
395
|
+
continue
|
|
396
|
+
nx = dxr * nxy32
|
|
397
|
+
nzv = hgt * nz32
|
|
398
|
+
zbuf[y, x] = depth
|
|
399
|
+
_shade_pixel(color, y, x, alpha_on, depth,
|
|
400
|
+
nx, ny, nzv, ar, ag, ab, flat,
|
|
401
|
+
l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
|
|
402
|
+
ambient, fill_w, spec_w, depth_cue, zmin, zspan,
|
|
403
|
+
n_squares)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def render_frame(color, zbuf, alpha_on, bands,
|
|
407
|
+
sx, sy, sz, radii, acol, aflat, order,
|
|
408
|
+
barr, bcol, bflat,
|
|
409
|
+
zoom, ox_s, oy_s,
|
|
410
|
+
l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
|
|
411
|
+
ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares):
|
|
412
|
+
"""Thin wrapper so _run_once and render.py share one call signature."""
|
|
413
|
+
_render_kernel(color, zbuf, alpha_on, bands,
|
|
414
|
+
sx, sy, sz, radii, acol, aflat, order,
|
|
415
|
+
barr, bcol, bflat,
|
|
416
|
+
zoom, ox_s, oy_s,
|
|
417
|
+
l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
|
|
418
|
+
ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares)
|
|
@@ -71,6 +71,8 @@ def make_parser() -> argparse.ArgumentParser:
|
|
|
71
71
|
p.add_argument("--backend", default="auto", choices=["auto", "cpu", "gl"],
|
|
72
72
|
help="rendering backend: numpy CPU raycaster, GPU (OpenGL), "
|
|
73
73
|
"or auto (GPU if a context can be created, else CPU)")
|
|
74
|
+
p.add_argument("--theme", default="auto", choices=["auto", "dark", "light"],
|
|
75
|
+
help="chrome color theme; auto detects the terminal's own background")
|
|
74
76
|
p.add_argument("--size", default="0x0", help="pixel size WxH for --render/--kitty (0=auto)")
|
|
75
77
|
p.add_argument("--supersample", type=int, default=2, help="anti-aliasing factor for stills")
|
|
76
78
|
p.add_argument("--rotate", nargs=2, type=float, metavar=("YAW", "PITCH"),
|
|
@@ -93,6 +95,23 @@ def make_parser() -> argparse.ArgumentParser:
|
|
|
93
95
|
return p
|
|
94
96
|
|
|
95
97
|
|
|
98
|
+
def _apply_theme_arg(args) -> None:
|
|
99
|
+
"""--theme is the highest-precedence source in theme.resolve()'s ladder
|
|
100
|
+
(see docs/design/theme-and-aesthetics.md sec 2) -- implemented by setting
|
|
101
|
+
VIMOL_THEME before the Viewer is constructed, rather than adding a
|
|
102
|
+
parallel parameter to Viewer.__init__ for what VIMOL_THEME already
|
|
103
|
+
covers.
|
|
104
|
+
|
|
105
|
+
"auto" is the DEFAULT, i.e. "the user passed no --theme at all", so it
|
|
106
|
+
must leave VIMOL_THEME alone rather than clearing it -- clearing would
|
|
107
|
+
mean the env var only ever worked for direct Viewer() construction and
|
|
108
|
+
never for the CLI, silently deleting the ladder's second rung on every
|
|
109
|
+
plain `vimol file.xyz`.
|
|
110
|
+
"""
|
|
111
|
+
if args.theme != "auto":
|
|
112
|
+
os.environ["VIMOL_THEME"] = args.theme
|
|
113
|
+
|
|
114
|
+
|
|
96
115
|
def _print_info(mol: Molecule):
|
|
97
116
|
print(f"name: {mol.name}")
|
|
98
117
|
print(f"formula: {mol.formula()}")
|
|
@@ -142,6 +161,7 @@ def _default_demo_path() -> Optional[str]:
|
|
|
142
161
|
|
|
143
162
|
def main(argv: List[str] | None = None) -> int:
|
|
144
163
|
args = make_parser().parse_args(argv)
|
|
164
|
+
_apply_theme_arg(args)
|
|
145
165
|
|
|
146
166
|
if args.version:
|
|
147
167
|
from . import __version__
|
|
@@ -99,43 +99,28 @@ def add_manual_bond(mol: Molecule, i: int, j: int, order: int = 1) -> bool:
|
|
|
99
99
|
return True
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
def
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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`).
|
|
102
|
+
def measure_value(mol: Molecule, sel: Sequence[int]) -> Optional[float]:
|
|
103
|
+
"""The numeric core of :func:`measurement`: euclidean distance (Å, 2
|
|
104
|
+
atoms), angle at the middle atom (degrees, 3 atoms), or the standard
|
|
105
|
+
signed dihedral about the middle bond (degrees, 4 atoms). ``None`` for
|
|
106
|
+
fewer than 2 picks. Extra picks beyond the first four are ignored, same
|
|
107
|
+
defensiveness as :func:`measurement`. Pure and side-effect-free, so it is
|
|
108
|
+
reusable both for the live status-bar readout and for evaluating a
|
|
109
|
+
pinned measurement against other structures (``StructureSet.measure``).
|
|
123
110
|
"""
|
|
124
111
|
n = len(sel)
|
|
125
112
|
if n < 2:
|
|
126
|
-
return
|
|
113
|
+
return None
|
|
127
114
|
pos = mol.positions
|
|
128
115
|
if n == 2:
|
|
129
116
|
i, j = sel[0], sel[1]
|
|
130
|
-
|
|
131
|
-
return f"d(#{i}–#{j}) = {d:.3f} Å"
|
|
117
|
+
return float(np.linalg.norm(pos[i] - pos[j]))
|
|
132
118
|
if n == 3:
|
|
133
119
|
i, j, k = sel[0], sel[1], sel[2]
|
|
134
120
|
v1 = pos[i] - pos[j]
|
|
135
121
|
v2 = pos[k] - pos[j]
|
|
136
122
|
cos_a = float(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)))
|
|
137
|
-
|
|
138
|
-
return f"∠(#{i}–#{j}–#{k}) = {angle:.1f}°"
|
|
123
|
+
return float(np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0))))
|
|
139
124
|
i, j, k, l = sel[0], sel[1], sel[2], sel[3]
|
|
140
125
|
b1 = pos[j] - pos[i]
|
|
141
126
|
b2 = pos[k] - pos[j]
|
|
@@ -145,8 +130,32 @@ def measurement(mol: Molecule, sel: Sequence[int]) -> str:
|
|
|
145
130
|
m1 = np.cross(n1, b2 / np.linalg.norm(b2))
|
|
146
131
|
x = float(np.dot(n1, n2))
|
|
147
132
|
y = float(np.dot(m1, n2))
|
|
148
|
-
|
|
149
|
-
|
|
133
|
+
return float(np.degrees(np.arctan2(y, x)))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def measurement(mol: Molecule, sel: Sequence[int]) -> str:
|
|
137
|
+
"""Render a live distance/angle/dihedral readout for an ordered pick list.
|
|
138
|
+
|
|
139
|
+
Pure and side-effect-free (no terminal, no widget state) so it is
|
|
140
|
+
testable on its own; the widget only owns *which* atoms are selected
|
|
141
|
+
(``measure_sel``), this just formats what they mean -- see
|
|
142
|
+
:func:`measure_value` for the numbers themselves.
|
|
143
|
+
|
|
144
|
+
Indices are rendered with the same ``#idx`` convention as the hover
|
|
145
|
+
readout (:meth:`MoleculeWidget.atom_info`).
|
|
146
|
+
"""
|
|
147
|
+
n = len(sel)
|
|
148
|
+
if n < 2:
|
|
149
|
+
return ""
|
|
150
|
+
value = measure_value(mol, sel)
|
|
151
|
+
if n == 2:
|
|
152
|
+
i, j = sel[0], sel[1]
|
|
153
|
+
return f"d(#{i}–#{j}) = {value:.3f} Å"
|
|
154
|
+
if n == 3:
|
|
155
|
+
i, j, k = sel[0], sel[1], sel[2]
|
|
156
|
+
return f"∠(#{i}–#{j}–#{k}) = {value:.1f}°"
|
|
157
|
+
i, j, k, l = sel[0], sel[1], sel[2], sel[3]
|
|
158
|
+
return f"φ(#{i}–#{j}–#{k}–#{l}) = {value:.1f}°"
|
|
150
159
|
|
|
151
160
|
|
|
152
161
|
def _cap(mol: Molecule, center_idx: int, center: np.ndarray, dirs: np.ndarray,
|
|
@@ -92,6 +92,14 @@ _COLORS = {
|
|
|
92
92
|
|
|
93
93
|
_DEFAULT_COLOR = (1.00, 0.41, 0.71) # hot pink for unknown elements
|
|
94
94
|
|
|
95
|
+
# Light-mode overrides for CPK entries that don't survive a light/white
|
|
96
|
+
# terminal background -- only the near-white ones move; everything else in
|
|
97
|
+
# _COLORS is saturated enough to read on either background.
|
|
98
|
+
_LIGHT_OVERRIDES = {
|
|
99
|
+
"H": (0.55, 0.55, 0.58), "He": (0.45, 0.75, 0.78), "Ag": (0.45, 0.45, 0.45),
|
|
100
|
+
"Pt": (0.45, 0.45, 0.55), "Hg": (0.40, 0.40, 0.55),
|
|
101
|
+
}
|
|
102
|
+
|
|
95
103
|
|
|
96
104
|
def normalize_symbol(sym: str) -> str:
|
|
97
105
|
"""Normalize an element token to canonical capitalization (e.g. 'FE' -> 'Fe')."""
|
|
@@ -117,6 +125,21 @@ def element_color(sym: str) -> tuple:
|
|
|
117
125
|
return _COLORS.get(normalize_symbol(sym), _DEFAULT_COLOR)
|
|
118
126
|
|
|
119
127
|
|
|
128
|
+
def themed_base_colors(symbols, base_colors, theme: str):
|
|
129
|
+
"""*base_colors* (N,3), with _LIGHT_OVERRIDES entries substituted where
|
|
130
|
+
*symbols* names one and *theme* is "light". Returns *base_colors*
|
|
131
|
+
unchanged (same array, no copy) for "dark" -- callers rely on this to
|
|
132
|
+
stay byte-identical to pre-theme rendering when the theme is dark."""
|
|
133
|
+
if theme != "light":
|
|
134
|
+
return base_colors
|
|
135
|
+
out = base_colors.copy()
|
|
136
|
+
for i, sym in enumerate(symbols):
|
|
137
|
+
override = _LIGHT_OVERRIDES.get(normalize_symbol(sym))
|
|
138
|
+
if override is not None:
|
|
139
|
+
out[i] = override
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
120
143
|
def element_name(sym: str) -> str:
|
|
121
144
|
"""Full element name, e.g. 'Carbon' for 'C'. Falls back to the symbol itself."""
|
|
122
145
|
z = symbol_to_z(sym)
|